diff --git a/src/Aspire.Hosting.Maui/Utilities/MauiEnvironmentHelper.cs b/src/Aspire.Hosting.Maui/Utilities/MauiEnvironmentHelper.cs index 68673d43e6e..3045f801b78 100644 --- a/src/Aspire.Hosting.Maui/Utilities/MauiEnvironmentHelper.cs +++ b/src/Aspire.Hosting.Maui/Utilities/MauiEnvironmentHelper.cs @@ -36,30 +36,24 @@ internal static class MauiEnvironmentHelper var environmentVariables = new Dictionary(StringComparer.OrdinalIgnoreCase); var encodedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - // Collect all environment variables from the resource - await resource.ProcessEnvironmentVariableValuesAsync( - executionContext, - (key, unprocessed, processed, ex) => - { - if (ex is not null || string.IsNullOrEmpty(key) || processed is not string value) - { - return; - } + (var executionConfiguration, _) = await resource.ExecutionConfigurationBuilder() + .WithEnvironmentVariables() + .BuildAsync(executionContext, logger, cancellationToken) + .ConfigureAwait(false); - // Android environment variables must be uppercase to be properly read by the runtime - var normalizedKey = key.ToUpperInvariant(); - var encodedValue = EncodeSemicolons(value, out var wasEncoded); + // Normalize all the environment variables for the resource + foreach (var envVar in executionConfiguration.EnvironmentVariables) + { + var normalizedKey = envVar.Key.ToUpperInvariant(); + var encodedValue = EncodeSemicolons(envVar.Value, out var wasEncoded); - environmentVariables[normalizedKey] = encodedValue; + environmentVariables[normalizedKey] = encodedValue; - if (wasEncoded) - { - encodedKeys.Add(normalizedKey); - } - }, - logger, - cancellationToken: cancellationToken - ).ConfigureAwait(false); + if (wasEncoded) + { + encodedKeys.Add(normalizedKey); + } + } // If no environment variables, return null if (environmentVariables.Count == 0) @@ -226,26 +220,13 @@ private static string EncodeSemicolons(string value, out bool wasEncoded) ILogger logger, CancellationToken cancellationToken) { - var environmentVariables = new Dictionary(StringComparer.Ordinal); - - // Collect all environment variables from the resource - await resource.ProcessEnvironmentVariableValuesAsync( - executionContext, - (key, unprocessed, processed, ex) => - { - if (ex is not null || string.IsNullOrEmpty(key) || processed is not string value) - { - return; - } - - environmentVariables[key] = value; - }, - logger, - cancellationToken: cancellationToken - ).ConfigureAwait(false); + (var executionConfiguration, _) = await resource.ExecutionConfigurationBuilder() + .WithEnvironmentVariables() + .BuildAsync(executionContext, logger, cancellationToken) + .ConfigureAwait(false); // If no environment variables, return null - if (environmentVariables.Count == 0) + if (!executionConfiguration.EnvironmentVariables.Any()) { return null; } @@ -262,7 +243,7 @@ await resource.ProcessEnvironmentVariableValuesAsync( var targetsFilePath = Path.Combine(tempDirectory, $"{sanitizedName}-{uniqueId}.targets"); // Generate the targets file content - var targetsContent = GenerateiOSTargetsFileContent(environmentVariables); + var targetsContent = GenerateiOSTargetsFileContent(executionConfiguration.EnvironmentVariables.ToDictionary()); // Write the file await File.WriteAllTextAsync(targetsFilePath, targetsContent, Encoding.UTF8, cancellationToken).ConfigureAwait(false); @@ -287,17 +268,17 @@ private static string GenerateiOSTargetsFileContent(Dictionary e // Create an ItemGroup to add environment variables using MlaunchEnvironmentVariables // iOS apps need environment variables passed to mlaunch as KEY=VALUE pairs var itemGroup = new XElement("ItemGroup"); - + foreach (var (key, value) in environmentVariables.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)) { // Encode semicolons as %3B to prevent MSBuild from treating them as item separators var encodedValue = value.Replace(";", "%3B", StringComparison.Ordinal); - + // Add as MlaunchEnvironmentVariables item with Include="KEY=VALUE" - itemGroup.Add(new XElement("MlaunchEnvironmentVariables", + itemGroup.Add(new XElement("MlaunchEnvironmentVariables", new XAttribute("Include", $"{key}={encodedValue}"))); } - + projectElement.Add(itemGroup); // Add a diagnostic message target to show what's being forwarded diff --git a/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs new file mode 100644 index 00000000000..dc2a2a6d682 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ArgumentsExecutionConfigurationGatherer.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Gathers command line arguments for resources. +/// +internal class ArgumentsExecutionConfigurationGatherer : IResourceExecutionConfigurationGatherer +{ + /// + public async ValueTask GatherAsync(IResourceExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) + { + if (resource.TryGetAnnotationsOfType(out var callbacks)) + { + var callbackContext = new CommandLineArgsCallbackContext(context.Arguments, resource, cancellationToken) + { + Logger = resourceLogger, + ExecutionContext = executionContext + }; + + foreach (var callback in callbacks) + { + await callback.Callback(callbackContext).ConfigureAwait(false); + } + } + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs new file mode 100644 index 00000000000..174f9b47c82 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/CertificateTrustExecutionConfigurationGatherer.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIRECERTIFICATES001 + +using System.Security.Cryptography.X509Certificates; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Gathers certificate trust configuration for resources that require it. +/// +internal class CertificateTrustExecutionConfigurationGatherer : IResourceExecutionConfigurationGatherer +{ + private readonly Func _configContextFactory; + + /// + /// Initializes a new instance of . + /// + /// A factory for configuring certificate trust configuration properties. + public CertificateTrustExecutionConfigurationGatherer(Func configContextFactory) + { + _configContextFactory = configContextFactory; + } + + /// + public async ValueTask GatherAsync(IResourceExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) + { + var developerCertificateService = executionContext.ServiceProvider.GetRequiredService(); + var trustDevCert = developerCertificateService.TrustCertificate; + + // Add additional certificate trust configuration metadata + var additionalData = new CertificateTrustExecutionConfigurationData(); + context.AddAdditionalData(additionalData); + + additionalData.Scope = CertificateTrustScope.Append; + var certificates = new X509Certificate2Collection(); + if (resource.TryGetLastAnnotation(out var caAnnotation)) + { + foreach (var certCollection in caAnnotation.CertificateAuthorityCollections) + { + certificates.AddRange(certCollection.Certificates); + } + + trustDevCert = caAnnotation.TrustDeveloperCertificates.GetValueOrDefault(trustDevCert); + additionalData.Scope = caAnnotation.Scope.GetValueOrDefault(additionalData.Scope); + } + + if (additionalData.Scope == CertificateTrustScope.None) + { + // No certificate trust configuration to apply + return; + } + + if (additionalData.Scope == CertificateTrustScope.System) + { + // Read the system root certificates and add them to the collection + certificates.AddRootCertificates(); + } + + if (executionContext.IsRunMode && trustDevCert) + { + foreach (var cert in developerCertificateService.Certificates) + { + certificates.Add(cert); + } + } + + additionalData.Certificates.AddRange(certificates); + + if (!additionalData.Certificates.Any()) + { + // No certificates to configure + resourceLogger.LogInformation("No custom certificate authorities to configure for '{ResourceName}'. Default certificate authority trust behavior will be used.", resource.Name); + return; + } + + var configurationContext = _configContextFactory(additionalData.Scope); + + // Apply default OpenSSL environment configuration for certificate trust + context.EnvironmentVariables["SSL_CERT_DIR"] = configurationContext.CertificateDirectoriesPath; + + if (additionalData.Scope != CertificateTrustScope.Append) + { + context.EnvironmentVariables["SSL_CERT_FILE"] = configurationContext.CertificateBundlePath; + } + + var callbackContext = new CertificateTrustConfigurationCallbackAnnotationContext + { + ExecutionContext = executionContext, + Resource = resource, + Scope = additionalData.Scope, + CertificateBundlePath = configurationContext.CertificateBundlePath, + CertificateDirectoriesPath = configurationContext.CertificateDirectoriesPath, + Arguments = context.Arguments, + EnvironmentVariables = context.EnvironmentVariables, + CancellationToken = cancellationToken, + }; + + if (resource.TryGetAnnotationsOfType(out var callbacks)) + { + foreach (var callback in callbacks) + { + await callback.Callback(callbackContext).ConfigureAwait(false); + } + } + + if (additionalData.Scope == CertificateTrustScope.System) + { + resourceLogger.LogInformation("Resource '{ResourceName}' has a certificate trust scope of '{Scope}'. Automatically including system root certificates in the trusted configuration.", resource.Name, Enum.GetName(additionalData.Scope)); + } + + } +} + +/// +/// Metadata about the resource certificate trust configuration. +/// +public class CertificateTrustExecutionConfigurationData : IResourceExecutionConfigurationData +{ + /// + /// The certificate trust scope for the resource. + /// + public CertificateTrustScope Scope { get; internal set; } + + /// + /// The collection of certificates to trust. + /// + public X509Certificate2Collection Certificates { get; } = new(); +} + +/// +/// Context for configuring certificate trust configuration properties. +/// +public class CertificateTrustExecutionConfigurationContext +{ + /// + /// The path to the certificate bundle file in the resource context (e.g., container filesystem). + /// + public required ReferenceExpression CertificateBundlePath { get; init; } + + /// + /// The path(s) to the certificate directories in the resource context (e.g., container filesystem). + /// + public required ReferenceExpression CertificateDirectoriesPath { get; init; } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/EnvironmentVariablesConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/EnvironmentVariablesConfigurationGatherer.cs new file mode 100644 index 00000000000..5e938cfcf81 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/EnvironmentVariablesConfigurationGatherer.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Gathers environment variables for resources. +/// +internal class EnvironmentVariablesExecutionConfigurationGatherer : IResourceExecutionConfigurationGatherer +{ + /// + public async ValueTask GatherAsync(IResourceExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) + { + if (resource.TryGetEnvironmentVariables(out var callbacks)) + { + var callbackContext = new EnvironmentCallbackContext(executionContext, resource, context.EnvironmentVariables, cancellationToken) + { + Logger = resourceLogger, + }; + + foreach (var callback in callbacks) + { + await callback.Callback(callbackContext).ConfigureAwait(false); + } + } + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs b/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs index 16117a21031..da51ee05c24 100644 --- a/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs +++ b/src/Aspire.Hosting/ApplicationModel/ExpressionResolver.cs @@ -41,8 +41,6 @@ async Task EvalExpressionAsync(ReferenceExpression expr, ValuePro var args = new object?[expr.ValueProviders.Count]; var isSensitive = false; - expr.WasResolved = true; - for (var i = 0; i < expr.ValueProviders.Count; i++) { var result = await ResolveInternalAsync(expr.ValueProviders[i], context).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/ApplicationModel/IProcessedResourceExecutionConfiguration.cs b/src/Aspire.Hosting/ApplicationModel/IProcessedResourceExecutionConfiguration.cs new file mode 100644 index 00000000000..c8b341dad25 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/IProcessedResourceExecutionConfiguration.cs @@ -0,0 +1,40 @@ +// 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.ApplicationModel; + +/// +/// Configuration (arguments and environment variables) to apply to a specific resource. +/// +public interface IProcessedResourceExecutionConfiguration +{ + /// + /// Gets the set of references such as or that were used to produce this configuration. + /// + IEnumerable References { get; } + + /// + /// Gets the arguments for the resource with the orgiginal unprocessed values included. + /// + IEnumerable<(object Unprocessed, string Processed, bool IsSensitive)> ArgumentsWithUnprocessed { get; } + + /// + /// Gets the processed arguments to apply to the resource. + /// + IEnumerable<(string Value, bool IsSensitive)> Arguments { get; } + + /// + /// Gets the environment variables to apply to the resource with the original unprocessed values included. + /// + IEnumerable> EnvironmentVariablesWithUnprocessed { get; } + + /// + /// Gets the processed environment variables to apply to the resource. + /// + IEnumerable> EnvironmentVariables { get; } + + /// + /// Gets additional configuration data associated with the resource configuration. + /// + IEnumerable AdditionalConfigurationData { get; } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationBuilder.cs b/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationBuilder.cs new file mode 100644 index 00000000000..6556cd15b47 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationBuilder.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Builder for gathering and resolving the execution configuration (arguments and environment variables) for a specific resource. +/// +public interface IResourceExecutionConfigurationBuilder +{ + /// + /// Adds a configuration gatherer to the builder. + /// + /// The configuration gatherer to add. + /// The current instance of the builder. + IResourceExecutionConfigurationBuilder AddExecutionConfigurationGatherer(IResourceExecutionConfigurationGatherer gatherer); + + /// + /// Builds the processed resource configuration (resolved arguments and environment variables). + /// + /// The distributed application execution context. + /// A logger instance for the resource. If none is provided, a default logger will be used. + /// A cancellation token. + /// A tuple of the resource configuration and any exceptions that occurred while processing it. + Task<(IProcessedResourceExecutionConfiguration, Exception?)> BuildAsync(DistributedApplicationExecutionContext executionContext, ILogger? resourceLogger = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationData.cs b/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationData.cs new file mode 100644 index 00000000000..cbd9921d92d --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationData.cs @@ -0,0 +1,13 @@ +// 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.ApplicationModel; + +/// +/// Additional data associated with a resource's execution configuration. +/// This allows gatherers to provide additional data required to properly configure or run +/// the resource. +/// +public interface IResourceExecutionConfigurationData +{ +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationGatherer.cs new file mode 100644 index 00000000000..47f7cdbc30a --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationGatherer.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Gathers resource configurations (arguments and environment variables) and optionally +/// applies additional metadata to the resource. +/// +public interface IResourceExecutionConfigurationGatherer +{ + /// + /// Gathers the relevant resource execution configuration (arguments, environment variables, and optionally additional custom data) + /// + /// The initial resource configuration context. + /// The resource for which configuration is being gathered. + /// The logger for the resource. + /// The execution context in which the resource is being configured. + /// A cancellation token. + /// A task representing the asynchronous operation. + ValueTask GatherAsync(IResourceExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationGathererContext.cs b/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationGathererContext.cs new file mode 100644 index 00000000000..8cd17d56ce0 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/IResourceExecutionConfigurationGathererContext.cs @@ -0,0 +1,26 @@ +// 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.ApplicationModel; + +/// +/// Resource configuration gatherer context. +/// +public interface IResourceExecutionConfigurationGathererContext +{ + /// + /// Collection of unprocessed resource command line arguments. + /// + List Arguments { get; } + + /// + /// Collection of unprocessed resource environment variables. + /// + Dictionary EnvironmentVariables { get; } + + /// + /// Adds metadata associated with the resource configuration. + /// + /// The metadata to add. + void AddAdditionalData(IResourceExecutionConfigurationData metadata); +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/ProcessedResourceExecutionConfiguration.cs b/src/Aspire.Hosting/ApplicationModel/ProcessedResourceExecutionConfiguration.cs new file mode 100644 index 00000000000..09ebe6d6492 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ProcessedResourceExecutionConfiguration.cs @@ -0,0 +1,28 @@ +// 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.ApplicationModel; + +/// +/// Represents the configuration (arguments and environment variables) to apply to a specific resource. +/// +internal class ProcessedResourceExecutionConfiguration : IProcessedResourceExecutionConfiguration +{ + /// + public required IEnumerable References { get; init; } + + /// + public required IEnumerable<(object Unprocessed, string Processed, bool IsSensitive)> ArgumentsWithUnprocessed { get; init; } + + /// + public IEnumerable<(string Value, bool IsSensitive)> Arguments => ArgumentsWithUnprocessed.Select(arg => (arg.Processed, arg.IsSensitive)); + + /// + public required IEnumerable> EnvironmentVariablesWithUnprocessed { get; init; } + + /// + public IEnumerable> EnvironmentVariables => EnvironmentVariablesWithUnprocessed.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Processed)); + + /// + public required IEnumerable AdditionalConfigurationData { get; init; } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs index eafdf3240ea..52e30e32c2a 100644 --- a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs +++ b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs @@ -63,11 +63,6 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri public string ValueExpression => string.Format(CultureInfo.InvariantCulture, Format, _manifestExpressions); - /// - /// Indicates whether this expression was ever referenced to get its value. - /// - internal bool WasResolved { get; set; } - /// /// Gets the value of the expression. The final string value after evaluating the format string and its parameters. /// @@ -75,8 +70,6 @@ private ReferenceExpression(string format, IValueProvider[] valueProviders, stri /// A . public async ValueTask GetValueAsync(ValueProviderContext context, CancellationToken cancellationToken) { - WasResolved = true; - // NOTE: any logical changes to this method should also be made to ExpressionResolver.EvalExpressionAsync if (Format.Length == 0) { diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationBuilder.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationBuilder.cs new file mode 100644 index 00000000000..917eeb0c7ca --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationBuilder.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Provides a builder for constructing an for a specific resource in the distributed application model. +/// This resolves command line arguments and environment variables and potentially additional metadata through registered gatherers. +/// +/// +/// +/// Use when you need to programmatically assemble configuration for a resource, +/// typically by aggregating multiple configuration sources using the gatherer pattern. This builder collects configuration +/// from registered instances, which encapsulate logic for gathering resource-specific +/// command line arguments, environment variables, and other metadata. +/// +/// +/// The gatherer pattern allows for modular and extensible configuration assembly, where each gatherer can contribute part of the +/// final configuration and allows for collecting only the relevant configuration supported in a given context (i.e. only applying certificate +/// configuration gatherers in supported environments). +/// +/// +/// Typical usage involves creating a builder for a resource, adding one or more configuration gatherers, and then building the +/// configuration asynchronously. +/// +/// +/// +/// +/// var resolvedConfiguration = await ResourceExecutionConfigurationBuilder +/// .Create(myResource); +/// .WithArguments() +/// .WithEnvironmentVariables() +/// .BuildAsync(executionContext).ConfigureAwait(false); +/// +/// +internal class ResourceExecutionConfigurationBuilder : IResourceExecutionConfigurationBuilder +{ + private readonly IResource _resource; + private readonly List _gatherers = new(); + + private ResourceExecutionConfigurationBuilder(IResource resource) + { + _resource = resource; + } + + /// + /// Creates a new instance of . + /// + /// The resource to build the configuration for. + /// A new . + /// + /// Use the ExecutionConfigurationBuilder extension method on instead of creating a + /// builder directly. + /// + public static IResourceExecutionConfigurationBuilder Create(IResource resource) + { + return new ResourceExecutionConfigurationBuilder(resource); + } + + /// + public IResourceExecutionConfigurationBuilder AddExecutionConfigurationGatherer(IResourceExecutionConfigurationGatherer gatherer) + { + _gatherers.Add(gatherer); + + return this; + } + + /// + public async Task<(IProcessedResourceExecutionConfiguration, Exception?)> BuildAsync(DistributedApplicationExecutionContext executionContext, ILogger? resourceLogger = null, CancellationToken cancellationToken = default) + { + resourceLogger ??= _resource.GetLogger(executionContext.ServiceProvider); + + var context = new ResourceExecutionConfigurationGathererContext(); + + foreach (var gatherer in _gatherers) + { + await gatherer.GatherAsync(context, _resource, resourceLogger, executionContext, cancellationToken).ConfigureAwait(false); + } + + return await context.ResolveAsync(_resource, resourceLogger, executionContext, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationBuilderExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationBuilderExtensions.cs new file mode 100644 index 00000000000..5f862b7c842 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationBuilderExtensions.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography.X509Certificates; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Provides extension methods for . +/// +public static class ResourceExecutionConfigurationBuilderExtensions +{ + /// + /// Adds a command line arguments configuration gatherer to the builder. + /// + /// The builder to add the configuration gatherer to. + /// The builder with the configuration gatherer added. + public static IResourceExecutionConfigurationBuilder WithArguments(this IResourceExecutionConfigurationBuilder builder) + { + return builder.AddExecutionConfigurationGatherer(new ArgumentsExecutionConfigurationGatherer()); + } + + /// + /// Adds an environment variables configuration gatherer to the builder. + /// + /// The builder to add the configuration gatherer to. + /// The builder with the configuration gatherer added. + public static IResourceExecutionConfigurationBuilder WithEnvironmentVariables(this IResourceExecutionConfigurationBuilder builder) + { + return builder.AddExecutionConfigurationGatherer(new EnvironmentVariablesExecutionConfigurationGatherer()); + } + + /// + /// Adds a certificate trust configuration gatherer to the builder. + /// + /// The builder to add the configuration gatherer to. + /// A factory function to create the configuration context. + /// The builder with the configuration gatherer added. + [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceExecutionConfigurationBuilder WithCertificateTrust(this IResourceExecutionConfigurationBuilder builder, Func configContextFactory) + { + return builder.AddExecutionConfigurationGatherer(new CertificateTrustExecutionConfigurationGatherer(configContextFactory)); + } + + /// + /// Adds a server authentication certificate configuration gatherer to the builder. + /// + /// The builder to add the configuration gatherer to. + /// A factory function to create the configuration context. + /// The builder with the configuration gatherer added. + [Experimental("ASPIRECERTIFICATES001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public static IResourceExecutionConfigurationBuilder WithServerAuthenticationCertificate(this IResourceExecutionConfigurationBuilder builder, Func configContextFactory) + { + return builder.AddExecutionConfigurationGatherer(new ServerAuthenticationCertificateExecutionConfigurationGatherer(configContextFactory)); + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationExtensions.cs new file mode 100644 index 00000000000..cd0fd0baae4 --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationExtensions.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Extension methods for . +/// +public static class ResourceExecutionConfigurationExtensions +{ + /// + /// Tries to get additional data of the specified type from the resource execution configuration. + /// This is additional data added by configuration gatherers beyond the standard arguments and environment variables. + /// + /// The type of additional data to retrieve. + /// The resource execution configuration. + /// The additional data if found. + /// True if the additional data was found; otherwise, false. + public static bool TryGetAdditionalData(this IProcessedResourceExecutionConfiguration configuration, [NotNullWhen(true)] out T? additionalData) where T : IResourceExecutionConfigurationData + { + foreach (var item in configuration.AdditionalConfigurationData) + { + if (item is T typedItem) + { + additionalData = typedItem; + return true; + } + } + + additionalData = default; + return false; + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationGathererContext.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationGathererContext.cs new file mode 100644 index 00000000000..ae873b57f6a --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExecutionConfigurationGathererContext.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +internal class ResourceExecutionConfigurationGathererContext : IResourceExecutionConfigurationGathererContext +{ + /// + public List Arguments { get; } = new(); + + /// + public Dictionary EnvironmentVariables { get; } = new(); + + /// + /// Additional configuration data collected during gathering. + /// + internal HashSet AdditionalConfigurationData { get; } = new(); + + /// + public void AddAdditionalData(IResourceExecutionConfigurationData metadata) + { + AdditionalConfigurationData.Add(metadata); + } + + /// + /// Resolves the actual from the gatherer context. + /// + /// The resource for which the configuration is being resolved. + /// The logger associated with the resource. + /// The execution context of the distributed application. + /// A token to monitor for cancellation requests. + /// + /// A task that represents the asynchronous operation. The task result contains the resolved resource configuration. + /// + internal async Task<(IProcessedResourceExecutionConfiguration, Exception?)> ResolveAsync( + IResource resource, + ILogger resourceLogger, + DistributedApplicationExecutionContext executionContext, + CancellationToken cancellationToken = default) + { + HashSet references = new(); + List<(object Unprocessed, string Value, bool IsSensitive)> resolvedArguments = new(Arguments.Count); + Dictionary resolvedEnvironmentVariables = new(EnvironmentVariables.Count); + List exceptions = new(); + + foreach (var argument in Arguments) + { + try + { + var resolvedValue = await resource.ResolveValueAsync(executionContext, resourceLogger, argument, null, cancellationToken).ConfigureAwait(false); + if (resolvedValue?.Value != null) + { + resolvedArguments.Add((argument, resolvedValue.Value, resolvedValue.IsSensitive)); + if (argument is IValueProvider or IManifestExpressionProvider) + { + references.Add(argument); + } + } + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + foreach (var kvp in EnvironmentVariables) + { + try + { + var resolvedValue = await resource.ResolveValueAsync(executionContext, resourceLogger, kvp.Value, null, cancellationToken).ConfigureAwait(false); + if (resolvedValue?.Value != null) + { + resolvedEnvironmentVariables[kvp.Key] = (kvp.Value, resolvedValue.Value); + if (kvp.Value is IValueProvider or IManifestExpressionProvider) + { + references.Add(kvp.Value); + } + } + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + return (new ProcessedResourceExecutionConfiguration + { + References = references, + ArgumentsWithUnprocessed = resolvedArguments, + EnvironmentVariablesWithUnprocessed = resolvedEnvironmentVariables, + AdditionalConfigurationData = AdditionalConfigurationData, + }, exceptions.Count == 0 ? null : new AggregateException("One or more errors occurred while resolving resource configuration.", exceptions)); + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index 2abe57207e8..a67d2259c06 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -2,14 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography.X509Certificates; -using Aspire.Hosting.Dcp.Model; -using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -#pragma warning disable ASPIRECERTIFICATES001 #pragma warning disable ASPIRECOMPUTE001 #pragma warning disable ASPIRECOMPUTE002 @@ -157,6 +153,41 @@ public static bool TryGetEnvironmentVariables(this IResource resource, [NotNullW return TryGetAnnotationsOfType(resource, out environmentVariables); } + /// + /// Gets a for the given resource. + /// + /// The resource to generate configuration for + /// A instance for the given resource. + /// + /// + /// This method is useful for building resource execution configurations (command line arguments and environment variables) + /// in a fluent manner. Individual configuration sources can be added to the builder before finalizing the configuration to + /// allow only supported configuration sources to be applied in a given execution context (run vs. publish, etc). + /// + /// + /// In particular, this is used to allow certificate-related features to contribute to the final config, but only in execution + /// contexts where they're supported. + /// + /// + /// + /// var resolvedConfiguration = await myResource.ExecutionConfigurationBuilder() + /// .WithArguments() + /// .WithEnvironmentVariables() + /// .BuildAsync(executionContext, resourceLogger: null, cancellationToken: cancellationToken) + /// .ConfigureAwait(false); + /// + /// foreach (var argument in resolveConfiguration.Arguments) + /// { + /// Console.WriteLine($"Argument: {argument.Value}"); + /// } + /// + /// + /// + public static IResourceExecutionConfigurationBuilder ExecutionConfigurationBuilder(this IResource resource) + { + return ResourceExecutionConfigurationBuilder.Create(resource); + } + /// /// Get the environment variables from the given resource. /// @@ -192,23 +223,15 @@ public static bool TryGetEnvironmentVariables(this IResource resource, [NotNullW /// /// /// + [Obsolete("Use ResourceExecutionConfigurationBuilder instead.")] public static async ValueTask> GetEnvironmentVariableValuesAsync(this IResourceWithEnvironment resource, DistributedApplicationOperation applicationOperation = DistributedApplicationOperation.Run) { - var env = new Dictionary(); - var executionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(applicationOperation)); - await resource.ProcessEnvironmentVariableValuesAsync( - executionContext, - (key, unprocessed, value, ex) => - { - if (value is string s) - { - env[key] = s; - } - }, - NullLogger.Instance).ConfigureAwait(false); + (var executionConfiguration, _) = await resource.ExecutionConfigurationBuilder() + .WithEnvironmentVariables() + .BuildAsync(new(applicationOperation), NullLogger.Instance, CancellationToken.None).ConfigureAwait(false); - return env; + return executionConfiguration.EnvironmentVariables.ToDictionary(); } /// @@ -244,25 +267,15 @@ await resource.ProcessEnvironmentVariableValuesAsync( /// /// /// + [Obsolete("Use ExecutionConfigurationBuilder instead.")] public static async ValueTask GetArgumentValuesAsync(this IResourceWithArgs resource, DistributedApplicationOperation applicationOperation = DistributedApplicationOperation.Run) { - var args = new List(); + (var argumentConfiguration, _) = await resource.ExecutionConfigurationBuilder() + .WithArguments() + .BuildAsync(new(applicationOperation), NullLogger.Instance, CancellationToken.None).ConfigureAwait(false); - var executionContext = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(applicationOperation)); - await resource.ProcessArgumentValuesAsync( - executionContext, - (unprocessed, value, ex, _) => - { - if (value is string s) - { - args.Add(s); - } - - }, - NullLogger.Instance).ConfigureAwait(false); - - return [.. args]; + return argumentConfiguration.Arguments.Select(a => a.Value).ToArray(); } /// @@ -274,6 +287,7 @@ await resource.ProcessArgumentValuesAsync( /// The logger used for logging information or errors during the retrieval of argument values. /// A token for cancelling the operation, if needed. /// A list of unprocessed argument values. + [Obsolete("Use ExecutionConfigurationBuilder instead.")] internal static async ValueTask> GatherArgumentValuesAsync( this IResource resource, DistributedApplicationExecutionContext executionContext, @@ -308,6 +322,7 @@ internal static async ValueTask> GatherArgumentValuesAsync( /// The logger used for logging information or errors during the argument processing. /// A token for cancelling the operation, if needed. /// A task representing the asynchronous operation. + [Obsolete("Use ExecutionConfigurationBuilder instead.")] internal static async ValueTask ProcessGatheredArgumentValuesAsync( this IResource resource, DistributedApplicationExecutionContext executionContext, @@ -347,6 +362,7 @@ internal static async ValueTask ProcessGatheredArgumentValuesAsync( /// The logger used for logging information or errors during the argument processing. /// A token for cancelling the operation, if needed. /// A task representing the asynchronous operation. + [Obsolete("Use ExecutionConfigurationBuilder instead.")] public static async ValueTask ProcessArgumentValuesAsync( this IResource resource, DistributedApplicationExecutionContext executionContext, @@ -369,6 +385,7 @@ public static async ValueTask ProcessArgumentValuesAsync( /// The logger used for logging information or errors during the gathering process. /// A token for cancelling the operation, if needed. /// A dictionary of unprocessed environment variable values. + [Obsolete("Use ExecutionConfigurationBuilder instead.")] internal static async ValueTask> GatherEnvironmentVariableValuesAsync( this IResource resource, DistributedApplicationExecutionContext executionContext, @@ -402,6 +419,7 @@ internal static async ValueTask> GatherEnvironmentVar /// The logger used to log any information or errors during the environment variables processing. /// A cancellation token to observe during the asynchronous operation. /// A task that represents the asynchronous operation. + [Obsolete("Use ExecutionConfigurationBuilder instead.")] internal static async ValueTask ProcessGatheredEnvironmentVariableValuesAsync( this IResource resource, DistributedApplicationExecutionContext executionContext, @@ -437,6 +455,7 @@ internal static async ValueTask ProcessGatheredEnvironmentVariableValuesAsync( /// The logger used to log any information or errors during the environment variables processing. /// A cancellation token to observe during the asynchronous operation. /// A task that represents the asynchronous operation. + [Obsolete("Use ExecutionConfigurationBuilder instead.")] public static async ValueTask ProcessEnvironmentVariableValuesAsync( this IResource resource, DistributedApplicationExecutionContext executionContext, @@ -532,399 +551,6 @@ internal static IEnumerable GetSupportedNetworks(this IResour return resource.IsContainer() ? [KnownNetworkIdentifiers.DefaultAspireContainerNetwork, KnownNetworkIdentifiers.LocalhostNetwork] : [KnownNetworkIdentifiers.LocalhostNetwork]; } - /// - /// Holds the resolved configuration for a resource, including arguments, environment variables, and certificate trust settings. - /// - internal class ResourceConfigurationContext - { - /// - /// The resolved command-line arguments for the resource. - /// - public required List<(string Value, bool IsSensitive)> Arguments { get; init; } - - /// - /// The resolved environment variables for the resource. - /// - public required List EnvironmentVariables { get; init; } - - /// - /// The trusted certificates for the resource, if any. - /// - public required X509Certificate2Collection TrustedCertificates { get; init; } - - /// - /// The certificate trust scope for the resource, if any. - /// - public required CertificateTrustScope CertificateTrustScope { get; init; } - - /// - /// The server authentication certificate for the resource, if any. - /// - public ServerAuthenticationCertificateConfigurationDetails? ServerAuthenticationCertificateConfiguration { get; init; } - - /// - /// Any exception that occurred during the configuration processing. - /// - public Exception? Exception { get; init; } - } - - /// - /// Process arguments and environment variable values for the specified resource in the given execution context. - /// - /// The resource to process configuration values for. - /// The execution context used during the processing of configuration values. - /// The resource specific logger used for logging information or errors during the processing of configuration values. - /// Should certificate trust callbacks be applied during processing. - /// Should server authentication certificate callbacks be applied during processing. - /// A function that takes the active and returns a with the paths to certificate resources. Required if withCertificateTrustConfig is true. - /// A factory function to create the context for building server authentication certificate configuration; provides the paths for the certificate, key, and PFX files. Required if withServerAuthCertificateConfig is true. - /// A token for cancelling the operation, if needed. - /// A containing resolved configuration. - internal static async ValueTask ProcessConfigurationValuesAsync( - this IResource resource, - DistributedApplicationExecutionContext executionContext, - ILogger resourceLogger, - bool withCertificateTrustConfig, - bool withServerAuthCertificateConfig, - Func? certificateTrustConfigContextFactory = null, - Func? serverAuthCertificateConfigContextFactory = null, - CancellationToken cancellationToken = default) - { - if (withCertificateTrustConfig) - { - ArgumentNullException.ThrowIfNull(certificateTrustConfigContextFactory); - } - - if (withServerAuthCertificateConfig) - { - ArgumentNullException.ThrowIfNull(serverAuthCertificateConfigContextFactory); - } - - var args = await GatherArgumentValuesAsync(resource, executionContext, resourceLogger, cancellationToken).ConfigureAwait(false); - var envVars = await GatherEnvironmentVariableValuesAsync(resource, executionContext, resourceLogger, cancellationToken).ConfigureAwait(false); - - var trustedCertificates = new X509Certificate2Collection(); - var certificateTrustScope = CertificateTrustScope.None; - if (withCertificateTrustConfig) - { - // If certificate trust is requested, apply the additional required argument and environment variable configuration - (args, envVars, certificateTrustScope, trustedCertificates) = await resource.GatherCertificateTrustConfigAsync( - executionContext, - args, - envVars, - resourceLogger, - certificateTrustConfigContextFactory!, - cancellationToken).ConfigureAwait(false); - } - - ServerAuthenticationCertificateConfigurationDetails? serverAuthCertificateConfiguration = null; - if (withServerAuthCertificateConfig) - { - (args, envVars, serverAuthCertificateConfiguration) = await resource.GatherServerAuthCertificateConfigAsync( - executionContext, - args, - envVars, - serverAuthCertificateConfigContextFactory!, - cancellationToken).ConfigureAwait(false); - } - - var resolvedArgs = new List<(string, bool)>(); - var resolvedEnvVars = new List(); - - List exceptions = []; - - await ProcessGatheredArgumentValuesAsync( - resource, - executionContext, - args, - (unprocessed, processed, ex, isSensitive) => - { - if (ex is not null) - { - exceptions.Add(ex); - - resourceLogger.LogCritical(ex, "Failed to apply argument value '{ArgKey}'. A dependency may have failed to start.", ex.Data["ArgKey"]); - } - else if (processed is { } argument) - { - resolvedArgs.Add((argument, isSensitive)); - } - }, - resourceLogger, - cancellationToken).ConfigureAwait(false); - - await ProcessGatheredEnvironmentVariableValuesAsync( - resource, - executionContext, - envVars, - (key, unprocessed, processed, ex) => - { - if (ex is not null) - { - exceptions.Add(ex); - - resourceLogger.LogCritical(ex, "Failed to apply environment variable '{EnvVarKey}'. A dependency may have failed to start.", key); - } - else if (processed is string s) - { - resolvedEnvVars.Add(new EnvVar { Name = key, Value = s }); - } - }, - resourceLogger, - cancellationToken).ConfigureAwait(false); - - Exception? exception = null; - if (exceptions.Any()) - { - exception = new AggregateException("One or more errors occurred while processing resource configuration.", exceptions); - } - - return new ResourceConfigurationContext - { - Arguments = resolvedArgs, - EnvironmentVariables = resolvedEnvVars, - CertificateTrustScope = certificateTrustScope, - TrustedCertificates = trustedCertificates!, - ServerAuthenticationCertificateConfiguration = serverAuthCertificateConfiguration, - Exception = exception, - }; - } - - /// - /// Context for building certificate trust configuration paths. - /// - internal class CertificateTrustConfigBuilderContext - { - /// - /// The path to the certificate bundle file in the resource context (e.g., container filesystem). - /// - public required ReferenceExpression CertificateBundlePath { get; init; } - - /// - /// The path(s) to the certificate directories in the resource context (e.g., container filesystem). - /// - public required ReferenceExpression CertificateDirectoriesPath { get; init; } - } - - /// - /// Gathers trusted certificates configuration for the specified resource within the given execution context. - /// This may produce additional and - /// annotations on the resource to configure certificate trust as needed and therefore must be run before - /// - /// and are called. - /// - /// The resource for which to process the certificate trust configuration. - /// The execution context used during the processing. - /// Existing arguments that will be used to initialize the context for the config callback. - /// Existing environment variables that will be used to initialize the context for the config callback. - /// The logger used for logging information during the processing. - /// A function that takes the active and returns a representing the paths to a custom certificate bundle and directories for the resource. - /// A cancellation token to observe while processing. - /// A task that represents the asynchronous operation. - internal static async ValueTask<(List, Dictionary, CertificateTrustScope, X509Certificate2Collection)> GatherCertificateTrustConfigAsync( - this IResource resource, - DistributedApplicationExecutionContext executionContext, - List arguments, - Dictionary environmentVariables, - ILogger logger, - Func configContextFactory, - CancellationToken cancellationToken = default) - { - var developerCertificateService = executionContext.ServiceProvider.GetRequiredService(); - var trustDevCert = developerCertificateService.TrustCertificate; - - var certificates = new X509Certificate2Collection(); - var scope = CertificateTrustScope.Append; - if (resource.TryGetLastAnnotation(out var caAnnotation)) - { - foreach (var certCollection in caAnnotation.CertificateAuthorityCollections) - { - certificates.AddRange(certCollection.Certificates); - } - - trustDevCert = caAnnotation.TrustDeveloperCertificates.GetValueOrDefault(trustDevCert); - scope = caAnnotation.Scope.GetValueOrDefault(scope); - } - - if (scope == CertificateTrustScope.None) - { - return (arguments, environmentVariables, scope, new X509Certificate2Collection()); - } - - if (scope == CertificateTrustScope.System) - { - // Read the system root certificates and add them to the collection - certificates.AddRootCertificates(); - } - - if (executionContext.IsRunMode && trustDevCert) - { - foreach (var cert in developerCertificateService.Certificates) - { - certificates.Add(cert); - } - } - - if (!certificates.Any()) - { - logger.LogInformation("No custom certificate authorities to configure for '{ResourceName}'. Default certificate authority trust behavior will be used.", resource.Name); - return (arguments, environmentVariables, scope, new X509Certificate2Collection()); - } - - var configBuilderContext = configContextFactory(scope); - - // Apply default OpenSSL environment configuration for certificate trust - environmentVariables["SSL_CERT_DIR"] = configBuilderContext.CertificateDirectoriesPath; - - if (scope != CertificateTrustScope.Append) - { - environmentVariables["SSL_CERT_FILE"] = configBuilderContext.CertificateBundlePath; - } - - var context = new CertificateTrustConfigurationCallbackAnnotationContext - { - ExecutionContext = executionContext, - Resource = resource, - Scope = scope, - CertificateBundlePath = configBuilderContext.CertificateBundlePath, - CertificateDirectoriesPath = configBuilderContext.CertificateDirectoriesPath, - Arguments = arguments, - EnvironmentVariables = environmentVariables, - CancellationToken = cancellationToken, - }; - - if (resource.TryGetAnnotationsOfType(out var callbacks)) - { - foreach (var callback in callbacks) - { - await callback.Callback(context).ConfigureAwait(false); - } - } - - if (scope == CertificateTrustScope.System) - { - logger.LogInformation("Resource '{ResourceName}' has a certificate trust scope of '{Scope}'. Automatically including system root certificates in the trusted configuration.", resource.Name, Enum.GetName(scope)); - } - - return (context.Arguments, context.EnvironmentVariables, scope, certificates); - } - - /// - /// Provides paths for server authentication certificate configuration - /// - internal class ServerAuthCertificateConfigBuilderContext - { - public required ReferenceExpression CertificatePath { get; init; } - public required ReferenceExpression KeyPath { get; init; } - public required ReferenceExpression PfxPath { get; init; } - } - - /// - /// Holds the details of server authentication certificate configuration. - /// - internal sealed class ServerAuthenticationCertificateConfigurationDetails - { - /// - /// The server authentication certificate for the resource, if any. - /// - public required X509Certificate2 Certificate { get; init; } - - /// - /// Indicates whether the resource references a PEM key for server authentication. - /// - public required ReferenceExpression KeyPathReference { get; set; } - - /// - /// Indicates whether the resource references a PFX file for server authentication. - /// - public required ReferenceExpression PfxReference { get; set; } - - /// - /// The passphrase for the server authentication certificate, if any. - /// - public string? Password { get; init; } - } - - /// - /// Gathers server authentication certificate configuration for the specified resource within the given execution context. - /// - /// The resource for which to gather server authentication certificate configuration. - /// The execution context within which the configuration is being gathered. - /// Existing arguments that will be used to initialize the context for the config callback. - /// Existing environment variables that will be used to initialize the context for the config callback. - /// A factory function to create the context for building server authentication certificate configuration; provides the paths for the certificate, key, and PFX files. - /// A token to monitor for cancellation requests. - /// The resulting command line arguments, environment variables, and optionally the specific server authentication certificate configuration details. - internal static async ValueTask<(List arguments, Dictionary environmentVariables, ServerAuthenticationCertificateConfigurationDetails? details)> GatherServerAuthCertificateConfigAsync( - this IResource resource, - DistributedApplicationExecutionContext executionContext, - List arguments, - Dictionary environmentVariables, - Func certificateConfigContextFactory, - CancellationToken cancellationToken = default) - { - var effectiveAnnotation = new ServerAuthenticationCertificateAnnotation(); - if (resource.TryGetLastAnnotation(out var annotation)) - { - effectiveAnnotation = annotation; - } - - if (effectiveAnnotation is null) - { - // Should never happen - return (arguments, environmentVariables, null); - } - - X509Certificate2? certificate = effectiveAnnotation.Certificate; - if (certificate is null) - { - var developerCertificateService = executionContext.ServiceProvider.GetRequiredService(); - if (effectiveAnnotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.UseForServerAuthentication)) - { - certificate = developerCertificateService.Certificates.FirstOrDefault(); - } - } - - if (certificate is null) - { - // No certificate to configure, do nothing - return (arguments, environmentVariables, null); - } - - var configBuilderContext = certificateConfigContextFactory(certificate); - - var context = new ServerAuthenticationCertificateConfigurationCallbackAnnotationContext - { - ExecutionContext = executionContext, - Resource = resource, - Arguments = arguments, - EnvironmentVariables = environmentVariables, - CertificatePath = configBuilderContext.CertificatePath, - KeyPath = configBuilderContext.KeyPath, - PfxPath = configBuilderContext.PfxPath, - Password = effectiveAnnotation.Password, - CancellationToken = cancellationToken, - }; - - foreach (var callback in resource.TryGetAnnotationsOfType(out var callbacks) ? callbacks : Enumerable.Empty()) - { - await callback.Callback(context).ConfigureAwait(false); - } - - string? password = effectiveAnnotation.Password is not null ? await effectiveAnnotation.Password.GetValueAsync(cancellationToken).ConfigureAwait(false) : null; - - return ( - arguments, - environmentVariables, - new ServerAuthenticationCertificateConfigurationDetails() - { - Certificate = certificate, - Password = password, - KeyPathReference = context.KeyPath, - PfxReference = context.PfxPath, - }); - } - internal static async ValueTask ResolveValueAsync( this IResource resource, DistributedApplicationExecutionContext executionContext, @@ -1543,4 +1169,16 @@ internal static async Task GetFullRemoteImageNameAsync( var registry = resource.GetContainerRegistry(); return await pushOptions.GetFullRemoteImageNameAsync(registry, cancellationToken).ConfigureAwait(false); } + + /// + /// Gets a logger for the specified resource using the provided service provider. + /// + /// The resource to get the logger for. + /// The service provider to resolve dependencies. + /// A logger instance for the specified resource. + internal static ILogger GetLogger(this IResource resource, IServiceProvider serviceProvider) + { + var resourceLoggerService = serviceProvider.GetRequiredService(); + return resourceLoggerService.GetLogger(resource); + } } diff --git a/src/Aspire.Hosting/ApplicationModel/ServerAuthenticationCertificateAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/ServerAuthenticationCertificateAnnotation.cs index 2ad387f9ead..f2e187e3512 100644 --- a/src/Aspire.Hosting/ApplicationModel/ServerAuthenticationCertificateAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/ServerAuthenticationCertificateAnnotation.cs @@ -7,6 +7,14 @@ namespace Aspire.Hosting.ApplicationModel; +/// +/// Represents a resource that resolves to a certificate +/// +public interface ICertificateResource : IResource, IValueProvider, IManifestExpressionProvider +{ + +} + /// /// An annotation that associates a certificate pair (public/private key) with a resource. /// diff --git a/src/Aspire.Hosting/ApplicationModel/ServerAuthenticationCertificateExecutionConfigurationGatherer.cs b/src/Aspire.Hosting/ApplicationModel/ServerAuthenticationCertificateExecutionConfigurationGatherer.cs new file mode 100644 index 00000000000..76f99660f7b --- /dev/null +++ b/src/Aspire.Hosting/ApplicationModel/ServerAuthenticationCertificateExecutionConfigurationGatherer.cs @@ -0,0 +1,192 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIRECERTIFICATES001 + +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A gatherer that configures server authentication certificate configuration for a resource. +/// +internal class ServerAuthenticationCertificateExecutionConfigurationGatherer : IResourceExecutionConfigurationGatherer +{ + private readonly Func _configContextFactory; + + /// + /// Initializes a new instance of . + /// + /// A factory for configuring server authentication certificate configuration properties. + public ServerAuthenticationCertificateExecutionConfigurationGatherer(Func configContextFactory) + { + _configContextFactory = configContextFactory; + } + + /// + public async ValueTask GatherAsync(IResourceExecutionConfigurationGathererContext context, IResource resource, ILogger resourceLogger, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken = default) + { + var effectiveAnnotation = new ServerAuthenticationCertificateAnnotation(); + if (resource.TryGetLastAnnotation(out var annotation)) + { + effectiveAnnotation = annotation; + } + + X509Certificate2? certificate = effectiveAnnotation.Certificate; + if (certificate is null) + { + var developerCertificateService = executionContext.ServiceProvider.GetRequiredService(); + if (effectiveAnnotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.UseForServerAuthentication)) + { + certificate = developerCertificateService.Certificates.FirstOrDefault(); + } + } + + if (certificate is null) + { + return; + } + + var configurationContext = _configContextFactory(certificate); + + var additionalData = new ServerAuthenticationCertificateExecutionConfigurationData + { + Certificate = certificate, + KeyPathReference = configurationContext.KeyPath, + PfxPathReference = configurationContext.PfxPath, + Password = effectiveAnnotation.Password is not null ? await effectiveAnnotation.Password.GetValueAsync(cancellationToken).ConfigureAwait(false) : null, + }; + context.AddAdditionalData(additionalData); + + var callbackContext = new ServerAuthenticationCertificateConfigurationCallbackAnnotationContext + { + ExecutionContext = executionContext, + Resource = resource, + Arguments = context.Arguments, + EnvironmentVariables = context.EnvironmentVariables, + CertificatePath = configurationContext.CertificatePath, + // Must use the metadata references to ensure proper tracking of usage + KeyPath = additionalData.KeyPathReference, + PfxPath = additionalData.PfxPathReference, + Password = effectiveAnnotation.Password, + CancellationToken = cancellationToken, + }; + + foreach (var callback in resource.TryGetAnnotationsOfType(out var callbacks) ? callbacks : Enumerable.Empty()) + { + await callback.Callback(callbackContext).ConfigureAwait(false); + } + + } +} + +/// +/// Metadata for server authentication certificate configuration. +/// +public class ServerAuthenticationCertificateExecutionConfigurationData : IResourceExecutionConfigurationData +{ + private ReferenceExpression? _keyPathReference; + private TrackedReference? _trackedKeyPathReference; + + private ReferenceExpression? _pfxPathReference; + private TrackedReference? _trackedPfxPathReference; + + /// + /// The server authentication certificate for the resource, if any. + /// + public required X509Certificate2 Certificate { get; init; } + + /// + /// Reference expression that will resolve to the path of the server authentication certificate key in PEM format. + /// + public required ReferenceExpression KeyPathReference + { + get + { + return _keyPathReference!; + } + set + { + _trackedKeyPathReference = new TrackedReference(value); + _keyPathReference = ReferenceExpression.Create($"{_trackedKeyPathReference}"); + } + } + + /// + /// Indicates whether the key path was actually referenced in the resource configuration. + /// + public bool IsKeyPathReferenced => _trackedKeyPathReference?.WasResolved ?? false; + + /// + /// Reference expression that will resolve to the path of the server authentication certificate in PFX format. + /// + public required ReferenceExpression PfxPathReference + { + get + { + return _pfxPathReference!; + } + set + { + _trackedPfxPathReference = new TrackedReference(value); + _pfxPathReference = ReferenceExpression.Create($"{_trackedPfxPathReference}"); + } + } + + /// + /// Indicates whether the PFX path was actually referenced in the resource configuration. + /// + public bool IsPfxPathReferenced => _trackedPfxPathReference?.WasResolved ?? false; + + /// + /// The passphrase for the server authentication certificate, if any. + /// + public string? Password { get; init; } + + private class TrackedReference : IValueProvider, IManifestExpressionProvider + { + private readonly ReferenceExpression _reference; + + public TrackedReference(ReferenceExpression reference) + { + _reference = reference; + } + + public bool WasResolved { get; internal set; } + + public string ValueExpression => _reference.ValueExpression; + + public ValueTask GetValueAsync(CancellationToken cancellationToken = default) + { + WasResolved = true; + + return _reference.GetValueAsync(cancellationToken); + } + } +} + +/// +/// Configuration context for server authentication certificate configuration. +/// +public class ServerAuthenticationCertificateExecutionConfigurationContext +{ + /// + /// Expression that will resolve to the path of the server authentication certificate in PEM format. + /// For containers this will be a path inside the container. + /// + public required ReferenceExpression CertificatePath { get; init; } + + /// + /// Expression that will resolve to the path of the server authentication certificate key in PEM format. + /// For containers this will be a path inside the container. + /// + public required ReferenceExpression KeyPath { get; init; } + + /// + /// Expression that will resolve to the path of the server authentication certificate in PFX format. + /// For containers this will be a path inside the container. + /// + public required ReferenceExpression PfxPath { get; init; } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index c3afafda66d..21ea7432eb9 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1598,30 +1598,64 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou var certificatesOutputPath = Path.Join(certificatesRootDir, "certs"); var baseServerAuthOutputPath = Path.Join(certificatesRootDir, "private"); - // Build the environment and args for the executable, including certificate trust configuration. - var configContext = await er.ModelResource.ProcessConfigurationValuesAsync( - _executionContext, - resourceLogger, - withCertificateTrustConfig: true, - withServerAuthCertificateConfig: true, - certificateTrustConfigContextFactory: (scope) => new() - { - CertificateBundlePath = ReferenceExpression.Create($"{bundleOutputPath}"), - CertificateDirectoriesPath = ReferenceExpression.Create($"{certificatesOutputPath}"), - }, - serverAuthCertificateConfigContextFactory: (cert) => new() + (var configuration, var configException) = await er.ModelResource.ExecutionConfigurationBuilder() + .WithArguments() + .WithEnvironmentVariables() + .WithCertificateTrust(scope => + { + var dirs = new List { certificatesOutputPath }; + if (scope == CertificateTrustScope.Append) + { + var existing = Environment.GetEnvironmentVariable("SSL_CERT_DIR"); + if (!string.IsNullOrEmpty(existing)) + { + dirs.AddRange(existing.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)); + } + } + + return new() + { + CertificateBundlePath = ReferenceExpression.Create($"{bundleOutputPath}"), + // Build the SSL_CERT_DIR value by combining the new certs directory with any existing directories. + CertificateDirectoriesPath = ReferenceExpression.Create($"{string.Join(Path.PathSeparator, dirs)}"), + }; + }) + .WithServerAuthenticationCertificate(cert => new() { CertificatePath = ReferenceExpression.Create($"{Path.Join(baseServerAuthOutputPath, $"{cert.Thumbprint}.crt")}"), KeyPath = ReferenceExpression.Create($"{Path.Join(baseServerAuthOutputPath, $"{cert.Thumbprint}.key")}"), PfxPath = ReferenceExpression.Create($"{Path.Join(baseServerAuthOutputPath, $"{cert.Thumbprint}.pfx")}"), - }, - cancellationToken).ConfigureAwait(false); + }) + .BuildAsync(_executionContext, resourceLogger, cancellationToken) + .ConfigureAwait(false); - if (configContext.ServerAuthenticationCertificateConfiguration is not null) + // Add the certificates to the executable spec so they'll be placed in the DCP config + ExecutablePemCertificates? pemCertificates = null; + if (configuration.TryGetAdditionalData(out var certificateTrustConfiguration) + && certificateTrustConfiguration.Scope != CertificateTrustScope.None + && certificateTrustConfiguration.Certificates.Count > 0) { - var thumbprint = configContext.ServerAuthenticationCertificateConfiguration.Certificate.Thumbprint; - var publicCetificatePem = configContext.ServerAuthenticationCertificateConfiguration.Certificate.ExportCertificatePem(); - (var keyPem, var pfxBytes) = await GetCertificateKeyMaterialAsync(configContext.ServerAuthenticationCertificateConfiguration, cancellationToken).ConfigureAwait(false); + pemCertificates = new ExecutablePemCertificates + { + Certificates = certificateTrustConfiguration.Certificates.Select(c => + { + return new PemCertificate + { + Thumbprint = c.Thumbprint, + Contents = c.ExportCertificatePem(), + }; + }).DistinctBy(cert => cert.Thumbprint).ToList(), + ContinueOnError = true, + }; + } + + exe.Spec.PemCertificates = pemCertificates; + + if (configuration.TryGetAdditionalData(out var serverAuthenticationCertificateConfiguration)) + { + var thumbprint = serverAuthenticationCertificateConfiguration.Certificate.Thumbprint; + var publicCetificatePem = serverAuthenticationCertificateConfiguration.Certificate.ExportCertificatePem(); + (var keyPem, var pfxBytes) = await GetCertificateKeyMaterialAsync(serverAuthenticationCertificateConfiguration, cancellationToken).ConfigureAwait(false); if (OperatingSystem.IsWindows()) { @@ -1652,27 +1686,7 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou } } - // Add the certificates to the executable spec so they'll be placed in the DCP config - ExecutablePemCertificates? pemCertificates = null; - if (configContext.CertificateTrustScope != CertificateTrustScope.None && configContext.TrustedCertificates?.Any() == true) - { - pemCertificates = new ExecutablePemCertificates - { - Certificates = configContext.TrustedCertificates.Select(c => - { - return new PemCertificate - { - Thumbprint = c.Thumbprint, - Contents = c.ExportCertificatePem(), - }; - }).DistinctBy(cert => cert.Thumbprint).ToList(), - ContinueOnError = true, - }; - } - - exe.Spec.PemCertificates = pemCertificates; - - var launchArgs = BuildLaunchArgs(er, spec, configContext.Arguments); + var launchArgs = BuildLaunchArgs(er, spec, configuration.Arguments); var executableArgs = launchArgs.Where(a => !a.AnnotationOnly).Select(a => a.Value).ToList(); if (executableArgs.Count > 0) { @@ -1682,9 +1696,9 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou // Arg annotations are what is displayed in the dashboard. er.DcpResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, launchArgs.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive))); - spec.Env = configContext.EnvironmentVariables; + spec.Env = configuration.EnvironmentVariables.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(); - if (configContext.Exception is not null) + if (configException is not null) { throw new FailedToApplyEnvironmentException(); } @@ -1697,7 +1711,7 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou } } - private static List<(string Value, bool IsSensitive, bool AnnotationOnly)> BuildLaunchArgs(RenderedModelResource er, ExecutableSpec spec, List<(string Value, bool IsSensitive)> appHostArgs) + private static List<(string Value, bool IsSensitive, bool AnnotationOnly)> BuildLaunchArgs(RenderedModelResource er, ExecutableSpec spec, IEnumerable<(string Value, bool IsSensitive)> appHostArgs) { // Launch args is the final list of args that are displayed in the UI and possibly added to the executable spec. // They're built from app host resource model args and any args in the effective launch profile. @@ -1711,14 +1725,14 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou // Args in the launch profile is used when: // 1. The project is run as an executable. Launch profile args are combined with app host supplied args. // 2. The project is run by the IDE and no app host args are specified. - if (spec.ExecutionType == ExecutionType.Process || (spec.ExecutionType == ExecutionType.IDE && appHostArgs.Count == 0)) + if (spec.ExecutionType == ExecutionType.Process || (spec.ExecutionType == ExecutionType.IDE && !appHostArgs.Any())) { // When the .NET project is launched from an IDE the launch profile args are automatically added. // We still want to display the args in the dashboard so only add them to the custom arg annotations. var annotationOnly = spec.ExecutionType == ExecutionType.IDE; var launchProfileArgs = GetLaunchProfileArgs(project.GetEffectiveLaunchProfile()?.LaunchProfile); - if (launchProfileArgs.Count > 0 && appHostArgs.Count > 0) + if (launchProfileArgs.Count > 0 && appHostArgs.Any()) { // If there are app host args, add a double-dash to separate them from the launch args. launchProfileArgs.Insert(0, "--"); @@ -1920,13 +1934,10 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour var serverAuthCertificatesBasePath = $"{certificatesDestination}/private"; - // Build the environment and args for the executable, including certificate trust configuration. - var configContext = await cr.ModelResource.ProcessConfigurationValuesAsync( - _executionContext, - resourceLogger, - withCertificateTrustConfig: true, - withServerAuthCertificateConfig: true, - certificateTrustConfigContextFactory: (scope) => + (var configuration, var configException) = await cr.ModelResource.ExecutionConfigurationBuilder() + .WithArguments() + .WithEnvironmentVariables() + .WithCertificateTrust(scope => { var dirs = new List { certificatesDestination + "/certs" }; if (scope == CertificateTrustScope.Append) @@ -1941,22 +1952,25 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour // Build Linux PATH style colon-separated list of directories CertificateDirectoriesPath = ReferenceExpression.Create($"{string.Join(':', dirs)}"), }; - }, - serverAuthCertificateConfigContextFactory: (cert) => new() + }) + .WithServerAuthenticationCertificate(cert => new() { CertificatePath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.crt"), KeyPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.key"), PfxPath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{cert.Thumbprint}.pfx"), - }, - cancellationToken).ConfigureAwait(false); + }) + .BuildAsync(_executionContext, resourceLogger, cancellationToken) + .ConfigureAwait(false); // Add the certificates to the executable spec so they'll be placed in the DCP config ContainerPemCertificates? pemCertificates = null; - if (configContext.CertificateTrustScope != CertificateTrustScope.None && configContext.TrustedCertificates?.Any() == true) + if (configuration.TryGetAdditionalData(out var certificateTrustConfiguration) + && certificateTrustConfiguration.Scope != CertificateTrustScope.None + && certificateTrustConfiguration.Certificates.Count > 0) { pemCertificates = new ContainerPemCertificates { - Certificates = configContext.TrustedCertificates.Select(c => + Certificates = certificateTrustConfiguration.Certificates.Select(c => { return new PemCertificate { @@ -1968,7 +1982,7 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour ContinueOnError = true, }; - if (configContext.CertificateTrustScope != CertificateTrustScope.Append) + if (certificateTrustConfiguration.Scope != CertificateTrustScope.Append) { // If overriding the default resource CA bundle, then we want to copy our bundle to the well-known locations // used by common Linux distributions to make it easier to ensure applications pick it up. @@ -1982,19 +1996,19 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour var buildCreateFilesContext = new BuildCreateFilesContext { Resource = modelContainerResource, - CertificateTrustScope = configContext.CertificateTrustScope, + CertificateTrustScope = certificateTrustConfiguration?.Scope ?? CertificateTrustScope.None, CertificateTrustBundlePath = $"{certificatesDestination}/cert.pem", }; - if (configContext.ServerAuthenticationCertificateConfiguration is not null) + if (configuration.TryGetAdditionalData(out var serverAuthenticationCertificateConfiguration)) { - var thumbprint = configContext.ServerAuthenticationCertificateConfiguration.Certificate.Thumbprint; + var thumbprint = serverAuthenticationCertificateConfiguration.Certificate.Thumbprint; buildCreateFilesContext.ServerAuthenticationCertificateContext = new ContainerFileSystemCallbackServerAuthenticationCertificateContext { CertificatePath = ReferenceExpression.Create($"{serverAuthCertificatesBasePath}/{thumbprint}.crt"), - KeyPath = configContext.ServerAuthenticationCertificateConfiguration.KeyPathReference, - PfxPath = configContext.ServerAuthenticationCertificateConfiguration.PfxReference, - Password = configContext.ServerAuthenticationCertificateConfiguration.Password, + KeyPath = serverAuthenticationCertificateConfiguration.KeyPathReference, + PfxPath = serverAuthenticationCertificateConfiguration.PfxPathReference, + Password = serverAuthenticationCertificateConfiguration.Password, }; } @@ -2003,12 +2017,11 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour buildCreateFilesContext, cancellationToken).ConfigureAwait(false); - if (configContext.ServerAuthenticationCertificateConfiguration is not null) + if (serverAuthenticationCertificateConfiguration is not null) { - var thumbprint = configContext.ServerAuthenticationCertificateConfiguration.Certificate.Thumbprint; - var publicCertificatePem = configContext.ServerAuthenticationCertificateConfiguration.Certificate.ExportCertificatePem(); - (var keyPem, var pfxBytes) = await GetCertificateKeyMaterialAsync(configContext.ServerAuthenticationCertificateConfiguration, cancellationToken).ConfigureAwait(false); - + var thumbprint = serverAuthenticationCertificateConfiguration.Certificate.Thumbprint; + var publicCertificatePem = serverAuthenticationCertificateConfiguration.Certificate.ExportCertificatePem(); + (var keyPem, var pfxBytes) = await GetCertificateKeyMaterialAsync(serverAuthenticationCertificateConfiguration, cancellationToken).ConfigureAwait(false); var certificateFiles = new List() { new ContainerFileSystemEntry @@ -2052,7 +2065,7 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour } // Set the final args, env vars, and create files on the container spec - var args = configContext.Arguments.Select(a => a.Value); + var args = configuration.Arguments.Select(a => a.Value); // Set the final args, env vars, and create files on the container spec if (modelContainerResource is ContainerResource { ShellExecution: true }) { @@ -2062,8 +2075,8 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour { spec.Args = args.ToList(); } - dcpContainerResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, configContext.Arguments.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive))); - spec.Env = configContext.EnvironmentVariables; + dcpContainerResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, configuration.Arguments.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive))); + spec.Env = configuration.EnvironmentVariables.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(); spec.CreateFiles = createFiles; if (modelContainerResource is ContainerResource containerResource) @@ -2071,7 +2084,7 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour spec.Command = containerResource.Entrypoint; } - if (failedToApplyRunArgs || configContext.Exception is not null) + if (failedToApplyRunArgs || configException is not null) { throw new FailedToApplyEnvironmentException(); } @@ -2493,6 +2506,11 @@ private async Task> BuildCreateFilesAsync(BuildC }, cancellationToken).ConfigureAwait(false); + if (entries?.Any() != true) + { + continue; + } + createFiles.Add(new ContainerCreateFileSystem { Destination = a.DestinationPath, @@ -2541,7 +2559,7 @@ await modelResource.ProcessContainerRuntimeArgValues( /// A token that can be used to cancel the operation. /// A tuple containing the PEM-encoded key and PFX bytes, if appropriate for the configuration. /// - private async Task<(char[]? keyPem, byte[]? pfxBytes)> GetCertificateKeyMaterialAsync(ResourceExtensions.ServerAuthenticationCertificateConfigurationDetails configuration, CancellationToken cancellationToken) + private async Task<(char[]? keyPem, byte[]? pfxBytes)> GetCertificateKeyMaterialAsync(ServerAuthenticationCertificateExecutionConfigurationData configuration, CancellationToken cancellationToken) { var certificate = configuration.Certificate; var lookup = certificate.Thumbprint; @@ -2559,7 +2577,7 @@ await modelResource.ProcessContainerRuntimeArgValues( await _serverCertificateCacheSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (configuration.KeyPathReference.WasResolved) + if (configuration.IsKeyPathReferenced) { var keyFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.key"); if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) @@ -2632,7 +2650,7 @@ await modelResource.ProcessContainerRuntimeArgValues( } } - if (configuration.PfxReference.WasResolved) + if (configuration.IsPfxPathReferenced) { var pfxFileName = Path.Join(s_macOSUserDevCertificateLocation, $"{lookup}.pfx"); if (OperatingSystem.IsMacOS() && certificate.IsAspNetCoreDevelopmentCertificate()) diff --git a/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs b/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs index fe1651b6ec6..81ce2a13ef2 100644 --- a/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs +++ b/src/Aspire.Hosting/Orchestrator/ParameterProcessor.cs @@ -117,31 +117,16 @@ private async Task CollectDependentParameterResourcesAsync(DistributedApplicatio private async Task ProcessResourceDependenciesAsync(IResource resource, DistributedApplicationExecutionContext executionContext, Dictionary referencedParameters, HashSet currentDependencySet, CancellationToken cancellationToken) { - // Process environment variables - await resource.ProcessEnvironmentVariableValuesAsync( - executionContext, - (key, unprocessed, processed, ex) => - { - if (unprocessed is not null) - { - TryAddDependentParameters(unprocessed, referencedParameters, currentDependencySet); - } - }, - logger, - cancellationToken: cancellationToken).ConfigureAwait(false); - - // Process command line arguments - await resource.ProcessArgumentValuesAsync( - executionContext, - (unprocessed, expression, ex, _) => - { - if (unprocessed is not null) - { - TryAddDependentParameters(unprocessed, referencedParameters, currentDependencySet); - } - }, - logger, - cancellationToken: cancellationToken).ConfigureAwait(false); + // Process the resource's execution configuration to find referenced parameters + (var executionConfgiuration, _) = await resource.ExecutionConfigurationBuilder() + .WithArguments() + .WithEnvironmentVariables() + .BuildAsync(executionContext, logger, cancellationToken).ConfigureAwait(false); + + foreach (var reference in executionConfgiuration.References) + { + TryAddDependentParameters(reference, referencedParameters, currentDependencySet); + } } private static void TryAddDependentParameters(object? value, Dictionary referencedParameters, HashSet currentDependencySet) diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index 5ded1456723..38c1aa545e5 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -528,42 +528,35 @@ public void WriteBindings(IResource resource) /// The which contains annotations. public async Task WriteEnvironmentVariablesAsync(IResource resource) { - var env = new Dictionary(); + (var executionConfiguration, var exception) = await resource.ExecutionConfigurationBuilder() + .WithEnvironmentVariables() + .BuildAsync(ExecutionContext, NullLogger.Instance, CancellationToken) + .ConfigureAwait(false); - await resource.ProcessEnvironmentVariableValuesAsync( - ExecutionContext, - (key, unprocessed, processed, ex) => - { - if (ex is not null) - { - ExceptionDispatchInfo.Throw(ex); - } - - if (unprocessed is not null && processed is not null) - { - env[key] = (unprocessed, processed); - } - }, - NullLogger.Instance, - cancellationToken: CancellationToken).ConfigureAwait(false); + if (exception is not null) + { + ExceptionDispatchInfo.Throw(exception); + } - if (env.Count > 0) + if (!executionConfiguration.EnvironmentVariablesWithUnprocessed.Any()) { - Writer.WriteStartObject("env"); + return; + } - foreach (var (key, value) in env) - { - var (unprocessed, processed) = value; + Writer.WriteStartObject("env"); - var manifestExpression = GetManifestExpression(unprocessed, processed); + foreach (var kvp in executionConfiguration.EnvironmentVariablesWithUnprocessed) + { + var (unprocessed, processed) = kvp.Value; - Writer.WriteString(key, manifestExpression); + var manifestExpression = GetManifestExpression(unprocessed, processed); - TryAddDependentResources(unprocessed); - } + Writer.WriteString(kvp.Key, manifestExpression); - Writer.WriteEndObject(); + TryAddDependentResources(unprocessed); } + + Writer.WriteEndObject(); } /// @@ -573,40 +566,33 @@ await resource.ProcessEnvironmentVariableValuesAsync( /// The to await for completion. public async Task WriteCommandLineArgumentsAsync(IResource resource) { - var args = new List<(object, string)>(); - - await resource.ProcessArgumentValuesAsync( - ExecutionContext, - (unprocessed, expression, ex, _) => - { - if (ex is not null) - { - ExceptionDispatchInfo.Throw(ex); - } + (var executionConfiguration, var exception) = await resource.ExecutionConfigurationBuilder() + .WithArguments() + .BuildAsync(ExecutionContext, NullLogger.Instance, CancellationToken) + .ConfigureAwait(false); - if (unprocessed is not null && expression is not null) - { - args.Add((unprocessed, expression)); - } - }, - NullLogger.Instance, - cancellationToken: CancellationToken).ConfigureAwait(false); + if (exception is not null) + { + ExceptionDispatchInfo.Throw(exception); + } - if (args.Count > 0) + if (!executionConfiguration.ArgumentsWithUnprocessed.Any()) { - Writer.WriteStartArray("args"); + return; + } - foreach (var (unprocessed, expression) in args) - { - var manifestExpression = GetManifestExpression(unprocessed, expression); + Writer.WriteStartArray("args"); - Writer.WriteStringValue(manifestExpression); + foreach ((var Unprocessed, var Processed, _) in executionConfiguration.ArgumentsWithUnprocessed) + { + var manifestExpression = GetManifestExpression(Unprocessed, Processed); - TryAddDependentResources(unprocessed); - } + Writer.WriteStringValue(manifestExpression); - Writer.WriteEndArray(); + TryAddDependentResources(Unprocessed); } + + Writer.WriteEndArray(); } private void WriteContainerMounts(ContainerResource container) { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs index 851059c4fc5..de9d5445ed0 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureKeyVaultTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Azure.Provisioning; using Microsoft.Extensions.DependencyInjection; @@ -65,8 +66,17 @@ public async Task WithEnvironment_AddsKeyVaultSecretReference() var containerBuilder = builder.AddContainer("myContainer", "nginx") .WithEnvironment("MY_SECRET", secretReference); - var runEnv = await containerBuilder.Resource.GetEnvironmentVariableValuesAsync(DistributedApplicationOperation.Run); - var publishEnv = await containerBuilder.Resource.GetEnvironmentVariableValuesAsync(DistributedApplicationOperation.Publish); + var serviceProvider = builder.Services.BuildServiceProvider(); + + var runEnv = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + containerBuilder.Resource, + DistributedApplicationOperation.Run, + serviceProvider); + + var publishEnv = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( + containerBuilder.Resource, + DistributedApplicationOperation.Publish, + serviceProvider); var runKvp = Assert.Single(runEnv); var pubishKvp = Assert.Single(publishEnv); @@ -393,7 +403,7 @@ public void EmulatorSupport_IsEmulatorTrueWhenContainerPresent() using var builder = TestDistributedApplicationBuilder.Create(); var keyVault = builder.AddAzureKeyVault("kv"); - + // Simulate emulator by adding container annotation keyVault.Resource.Annotations.Add(new ContainerImageAnnotation { @@ -409,7 +419,7 @@ public async Task EmulatorSupport_ConnectionStringUsesEmulatorEndpointWhenIsEmul using var builder = TestDistributedApplicationBuilder.Create(); var keyVault = builder.AddAzureKeyVault("kv"); - + // Add container annotation to simulate emulator keyVault.Resource.Annotations.Add(new ContainerImageAnnotation { @@ -444,7 +454,7 @@ public async Task ConnectionStringRedirectAnnotation_RedirectsConnectionString() using var builder = TestDistributedApplicationBuilder.Create(); var keyVault = builder.AddAzureKeyVault("kv"); - + // Create a connection string resource to redirect to var redirectTarget = new ConnectionStringResource("redirect-target", ReferenceExpression.Create($"https://redirected-vault.vault.azure.net")); @@ -465,7 +475,7 @@ public async Task ConnectionStringRedirectAnnotation_TakesPrecedenceOverEmulator using var builder = TestDistributedApplicationBuilder.Create(); var keyVault = builder.AddAzureKeyVault("kv"); - + // Create a connection string resource to redirect to var redirectTarget = new ConnectionStringResource("redirect-target", ReferenceExpression.Create($"https://redirected-vault.vault.azure.net")); diff --git a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs index c311be1074b..7a66284836a 100644 --- a/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs +++ b/tests/Aspire.Hosting.DevTunnels.Tests/DevTunnelResourceBuilderExtensionsTests.cs @@ -2,7 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting.DevTunnels.Tests; @@ -25,7 +28,7 @@ public async Task WithReference_InjectsServiceDiscoveryEnvironmentVariablesWhenR tunnelPort.TunnelEndpointAnnotation.AllocatedEndpoint = new(tunnelPort.TunnelEndpointAnnotation, "test123.devtunnels.ms", 443); - var values = await consumer.Resource.GetEnvironmentVariableValuesAsync(); + var values = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(consumer.Resource, serviceProvider: builder.Services.BuildServiceProvider()).DefaultTimeout(); Assert.Equal("https://test123.devtunnels.ms:443", values["services__target__https__0"]); Assert.Equal("https://test123.devtunnels.ms:443", values["TARGET_HTTPS"]); @@ -130,10 +133,10 @@ public void GetEndpoint_WithResourceAndEndpointName_ReturnsEndpointWithErrorWhen .WithReference(target); var endpointRef = tunnel.GetEndpoint(target.Resource, "nonexistent"); - + Assert.NotNull(endpointRef); Assert.False(endpointRef.Exists); - + var ex = Assert.Throws(() => _ = endpointRef.EndpointAnnotation); Assert.Equal("The dev tunnel 'tunnel' has not been associated with 'nonexistent' on resource 'target'. Use 'WithReference(target)' on the dev tunnel to expose this endpoint.", ex.Message); } @@ -152,10 +155,10 @@ public void GetEndpoint_WithEndpointReference_ReturnsEndpointWithErrorWhenEndpoi var target2Endpoint = target2.GetEndpoint("https"); var endpointRef = tunnel.GetEndpoint(target2Endpoint); - + Assert.NotNull(endpointRef); Assert.False(endpointRef.Exists); - + var ex = Assert.Throws(() => _ = endpointRef.EndpointAnnotation); Assert.Equal("The dev tunnel 'tunnel' has not been associated with 'https' on resource 'target2'. Use 'WithReference(target2)' on the dev tunnel to expose this endpoint.", ex.Message); } @@ -170,10 +173,10 @@ public void GetEndpoint_WithResourceAndEndpointName_ReturnsEndpointWithErrorWhen var tunnel = builder.AddDevTunnel("tunnel"); var endpointRef = tunnel.GetEndpoint(target.Resource, "https"); - + Assert.NotNull(endpointRef); Assert.False(endpointRef.Exists); - + var ex = Assert.Throws(() => _ = endpointRef.EndpointAnnotation); Assert.Equal("The dev tunnel 'tunnel' has not been associated with 'https' on resource 'target'. Use 'WithReference(target)' on the dev tunnel to expose this endpoint.", ex.Message); } @@ -196,7 +199,7 @@ public void GetEndpoint_WithMultipleEndpoints_ReturnsCorrectTunnelEndpoint() Assert.NotNull(httpsTunnelEndpoint); Assert.Equal(DevTunnelPortResource.TunnelEndpointName, httpTunnelEndpoint.EndpointName); Assert.Equal(DevTunnelPortResource.TunnelEndpointName, httpsTunnelEndpoint.EndpointName); - + // Verify they reference different ports (implicitly through the annotation) Assert.NotSame(httpTunnelEndpoint.EndpointAnnotation, httpsTunnelEndpoint.EndpointAnnotation); } diff --git a/tests/Aspire.Hosting.Kafka.Tests/AddKafkaTests.cs b/tests/Aspire.Hosting.Kafka.Tests/AddKafkaTests.cs index fdae2a09128..77c314f8f28 100644 --- a/tests/Aspire.Hosting.Kafka.Tests/AddKafkaTests.cs +++ b/tests/Aspire.Hosting.Kafka.Tests/AddKafkaTests.cs @@ -4,6 +4,7 @@ using System.Net.Sockets; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Eventing; +using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; @@ -118,7 +119,7 @@ public async Task WithDataVolumeConfigureCorrectEnvironment() .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 27017)) .WithDataVolume("kafka-data"); - var config = await kafka.Resource.GetEnvironmentVariableValuesAsync(); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(kafka.Resource); var volumeAnnotation = kafka.Resource.Annotations.OfType().Single(); @@ -136,7 +137,7 @@ public async Task WithDataBindConfigureCorrectEnvironment() .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 27017)) .WithDataBindMount("kafka-data"); - var config = await kafka.Resource.GetEnvironmentVariableValuesAsync(); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(kafka.Resource); var volumeAnnotation = kafka.Resource.Annotations.OfType().Single(); @@ -186,9 +187,9 @@ public async Task KafkaEnvironmentCallbackIsIdempotent() var kafka = appBuilder.AddKafka("kafka") .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 27017)); - // Call GetEnvironmentVariableValuesAsync multiple times to ensure callbacks are idempotent - var config1 = await kafka.Resource.GetEnvironmentVariableValuesAsync(); - var config2 = await kafka.Resource.GetEnvironmentVariableValuesAsync(); + // Call GetEnvironmentVariablesAsync multiple times to ensure callbacks are idempotent + var config1 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(kafka.Resource); + var config2 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(kafka.Resource); // Both calls should succeed and return the same values Assert.Equal(config1.Count, config2.Count); @@ -217,9 +218,9 @@ await appBuilder.Eventing.PublishAsync( new BeforeResourceStartedEvent(kafkaUiResource, app.Services), EventDispatchBehavior.BlockingSequential); - // Call GetEnvironmentVariableValuesAsync multiple times to ensure callbacks are idempotent - var config1 = await kafkaUiResource.GetEnvironmentVariableValuesAsync(); - var config2 = await kafkaUiResource.GetEnvironmentVariableValuesAsync(); + // Call GetEnvironmentVariablesAsync multiple times to ensure callbacks are idempotent + var config1 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(kafkaUiResource); + var config2 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(kafkaUiResource); // Both calls should succeed and return the same values Assert.Equal(config1.Count, config2.Count); diff --git a/tests/Aspire.Hosting.MongoDB.Tests/AddMongoDBTests.cs b/tests/Aspire.Hosting.MongoDB.Tests/AddMongoDBTests.cs index ba2e4cbee3c..6de2a2f6f06 100644 --- a/tests/Aspire.Hosting.MongoDB.Tests/AddMongoDBTests.cs +++ b/tests/Aspire.Hosting.MongoDB.Tests/AddMongoDBTests.cs @@ -300,9 +300,9 @@ public async Task MongoExpressEnvironmentCallbackIsIdempotent() var appModel = app.Services.GetRequiredService(); var mongoExpressResource = Assert.Single(appModel.Resources.OfType()); - // Call GetEnvironmentVariableValuesAsync multiple times to ensure callbacks are idempotent - var config1 = await mongoExpressResource.GetEnvironmentVariableValuesAsync(); - var config2 = await mongoExpressResource.GetEnvironmentVariableValuesAsync(); + // Call GetEnvironmentVariablesAsync multiple times to ensure callbacks are idempotent + var config1 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(mongoExpressResource); + var config2 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(mongoExpressResource); // Both calls should succeed and return the same values Assert.Equal(config1.Count, config2.Count); diff --git a/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs b/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs index dbbce61e6ab..85b4df1699f 100644 --- a/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs +++ b/tests/Aspire.Hosting.MySql.Tests/AddMySqlTests.cs @@ -385,9 +385,9 @@ await appBuilder.Eventing.PublishAsync( new BeforeResourceStartedEvent(phpMyAdminResource, app.Services), EventDispatchBehavior.BlockingSequential); - // Call GetEnvironmentVariableValuesAsync multiple times to ensure callbacks are idempotent - var config1 = await phpMyAdminResource.GetEnvironmentVariableValuesAsync(); - var config2 = await phpMyAdminResource.GetEnvironmentVariableValuesAsync(); + // Call GetEnvironmentVariablesAsync multiple times to ensure callbacks are idempotent + var config1 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(phpMyAdminResource); + var config2 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(phpMyAdminResource); // Both calls should succeed and return the same values Assert.Equal(config1.Count, config2.Count); diff --git a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs index 30a1cd5cf18..cff56b73699 100644 --- a/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs +++ b/tests/Aspire.Hosting.PostgreSQL.Tests/AddPostgresTests.cs @@ -693,9 +693,9 @@ public async Task PostgresEnvironmentCallbackIsIdempotent() var postgres = appBuilder.AddPostgres("postgres") .WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5432)); - // Call GetEnvironmentVariableValuesAsync multiple times to ensure callbacks are idempotent - var config1 = await postgres.Resource.GetEnvironmentVariableValuesAsync(); - var config2 = await postgres.Resource.GetEnvironmentVariableValuesAsync(); + // Call GetEnvironmentVariablesAsync multiple times to ensure callbacks are idempotent + var config1 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(postgres.Resource); + var config2 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(postgres.Resource); // Both calls should succeed and return the same values Assert.Equal(config1.Count, config2.Count); diff --git a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs index 601da6192d6..d56630b3aeb 100644 --- a/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs +++ b/tests/Aspire.Hosting.Redis.Tests/AddRedisTests.cs @@ -305,7 +305,7 @@ public async Task WithRedisInsightProducesCorrectEnvironmentVariables() redis2.WithEndpoint("tcp", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5002)); var redisInsight = Assert.Single(builder.Resources.OfType()); - var envs = await redisInsight.GetEnvironmentVariableValuesAsync(); + var envs = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(redisInsight); Assert.Collection(envs, (item) => @@ -720,9 +720,9 @@ public async Task RedisInsightEnvironmentCallbackIsIdempotent() var appModel = app.Services.GetRequiredService(); var redisInsightResource = Assert.Single(appModel.Resources.OfType()); - // Call GetEnvironmentVariableValuesAsync multiple times to ensure callbacks are idempotent - var config1 = await redisInsightResource.GetEnvironmentVariableValuesAsync(); - var config2 = await redisInsightResource.GetEnvironmentVariableValuesAsync(); + // Call GetEnvironmentVariablesAsync multiple times to ensure callbacks are idempotent + var config1 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(redisInsightResource); + var config2 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(redisInsightResource); // Both calls should succeed and return the same values Assert.Equal(config1.Count, config2.Count); @@ -773,6 +773,7 @@ public void WithDeveloperCertificateKeyPairWithPasswordStoresPassword() } [Fact] + [RequiresCertificateStoreAccess] public void WithCertificateKeyPairUsesProvidedCertificate() { var builder = DistributedApplication.CreateBuilder(); @@ -788,6 +789,7 @@ public void WithCertificateKeyPairUsesProvidedCertificate() } [Fact] + [RequiresCertificateStoreAccess] public void WithCertificateKeyPairWithPasswordStoresPassword() { var builder = DistributedApplication.CreateBuilder(); @@ -804,6 +806,7 @@ public void WithCertificateKeyPairWithPasswordStoresPassword() } [Fact] + [RequiresCertificateStoreAccess] public async Task RedisWithCertificateHasCorrectConnectionString() { using var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 0aec7b47796..c8990ea5f91 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -144,13 +144,19 @@ public async Task BeforeStartAsync_DashboardContainsDebugSessionInfo(string? deb var context = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { ServiceProvider = TestServiceProvider.Instance }); var dashboardEnvironmentVariables = new ConcurrentDictionary(); - await dashboardResource.ProcessEnvironmentVariableValuesAsync(context, (key, _, value, _) => dashboardEnvironmentVariables[key] = value, new FakeLogger()).DefaultTimeout(); + + (var dashboardEnvironment, _) = await dashboardResource.ExecutionConfigurationBuilder() + .WithEnvironmentVariables() + .BuildAsync(context, new FakeLogger(), CancellationToken.None) + .DefaultTimeout(); + + var environmentVariables = dashboardEnvironment.EnvironmentVariables.ToDictionary(); // Assert - Assert.Equal(expectedDebugSessionPort?.ToString(), dashboardEnvironmentVariables.GetValueOrDefault(DashboardConfigNames.DebugSessionPortName.EnvVarName)); - Assert.Equal(debugSessionToken, dashboardEnvironmentVariables.GetValueOrDefault(DashboardConfigNames.DebugSessionTokenName.EnvVarName)); - Assert.Equal(debugSessionCert, dashboardEnvironmentVariables.GetValueOrDefault(DashboardConfigNames.DebugSessionServerCertificateName.EnvVarName)); - Assert.Equal(telemetryEnabled, bool.TryParse(dashboardEnvironmentVariables.GetValueOrDefault(DashboardConfigNames.DebugSessionTelemetryOptOutName.EnvVarName, null), out var b) ? b : null); + Assert.Equal(expectedDebugSessionPort?.ToString(), environmentVariables.GetValueOrDefault(DashboardConfigNames.DebugSessionPortName.EnvVarName)); + Assert.Equal(debugSessionToken, environmentVariables.GetValueOrDefault(DashboardConfigNames.DebugSessionTokenName.EnvVarName)); + Assert.Equal(debugSessionCert, environmentVariables.GetValueOrDefault(DashboardConfigNames.DebugSessionServerCertificateName.EnvVarName)); + Assert.Equal(telemetryEnabled, bool.TryParse(environmentVariables.GetValueOrDefault(DashboardConfigNames.DebugSessionTelemetryOptOutName.EnvVarName), out var b) ? b : null); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs index 9c97babb663..3816f4276f4 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs @@ -2043,6 +2043,7 @@ private static DcpExecutor CreateAppExecutor( ServiceProvider = new TestServiceProvider(configuration) .AddService(developerCertificateService) .AddService(Options.Create(dcpOptions)) + .AddService(resourceLoggerService) }), resourceLoggerService, new TestDcpDependencyCheckService(), diff --git a/tests/Aspire.Hosting.Tests/ResourceExecutionConfigurationGathererTests.cs b/tests/Aspire.Hosting.Tests/ResourceExecutionConfigurationGathererTests.cs new file mode 100644 index 00000000000..05c114c6c2f --- /dev/null +++ b/tests/Aspire.Hosting.Tests/ResourceExecutionConfigurationGathererTests.cs @@ -0,0 +1,605 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASPIRECERTIFICATES001 + +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Aspire.Hosting.Utils; +using Aspire.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.Tests; + +public class ResourceExecutionConfigurationGathererTests +{ + #region ArgumentsExecutionConfigurationGatherer Tests + + [Fact] + public async Task ArgumentsExecutionConfigurationGatherer_WithCommandLineArgsCallback_GathersArguments() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("test", "test.exe", ".") + .WithArgs("arg1", "arg2") + .Resource; + + await builder.BuildAsync(); + + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new ArgumentsExecutionConfigurationGatherer(); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Equal(2, context.Arguments.Count); + Assert.Equal("arg1", context.Arguments[0]); + Assert.Equal("arg2", context.Arguments[1]); + } + + [Fact] + public async Task ArgumentsExecutionConfigurationGatherer_WithMultipleCallbacks_GathersAllArguments() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("test", "test.exe", ".") + .WithArgs("arg1") + .WithArgs(ctx => ctx.Args.Add("arg2")) + .Resource; + + await builder.BuildAsync(); + + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new ArgumentsExecutionConfigurationGatherer(); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Equal(2, context.Arguments.Count); + Assert.Equal("arg1", context.Arguments[0]); + Assert.Equal("arg2", context.Arguments[1]); + } + + [Fact] + public async Task ArgumentsExecutionConfigurationGatherer_NoArgsAnnotations_DoesNothing() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("test", "test.exe", ".").Resource; + + await builder.BuildAsync(); + + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new ArgumentsExecutionConfigurationGatherer(); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Empty(context.Arguments); + } + + [Fact] + public async Task ArgumentsExecutionConfigurationGatherer_AsyncCallback_ExecutesCorrectly() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddExecutable("test", "test.exe", ".") + .WithArgs(async ctx => + { + await Task.Delay(1); + ctx.Args.Add("async-arg"); + }) + .Resource; + + await builder.BuildAsync(); + + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new ArgumentsExecutionConfigurationGatherer(); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Single(context.Arguments); + Assert.Equal("async-arg", context.Arguments[0]); + } + + #endregion + + #region EnvironmentVariablesExecutionConfigurationGatherer Tests + + [Fact] + public async Task EnvironmentVariablesExecutionConfigurationGatherer_WithEnvironmentCallback_GathersEnvironmentVariables() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddContainer("test", "image") + .WithEnvironment("KEY1", "value1") + .WithEnvironment("KEY2", "value2") + .Resource; + + await builder.BuildAsync(); + + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new EnvironmentVariablesExecutionConfigurationGatherer(); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Equal(2, context.EnvironmentVariables.Count); + Assert.Equal("value1", context.EnvironmentVariables["KEY1"]); + Assert.Equal("value2", context.EnvironmentVariables["KEY2"]); + } + + [Fact] + public async Task EnvironmentVariablesExecutionConfigurationGatherer_WithMultipleCallbacks_GathersAllVariables() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddContainer("test", "image") + .WithEnvironment("KEY1", "value1") + .WithEnvironment(ctx => ctx.EnvironmentVariables["KEY2"] = "value2") + .Resource; + + await builder.BuildAsync(); + + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new EnvironmentVariablesExecutionConfigurationGatherer(); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Equal(2, context.EnvironmentVariables.Count); + Assert.Equal("value1", context.EnvironmentVariables["KEY1"]); + Assert.Equal("value2", context.EnvironmentVariables["KEY2"]); + } + + [Fact] + public async Task EnvironmentVariablesExecutionConfigurationGatherer_NoEnvironmentAnnotations_DoesNothing() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddContainer("test", "image").Resource; + + await builder.BuildAsync(); + + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new EnvironmentVariablesExecutionConfigurationGatherer(); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Empty(context.EnvironmentVariables); + } + + [Fact] + public async Task EnvironmentVariablesExecutionConfigurationGatherer_AsyncCallback_ExecutesCorrectly() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddContainer("test", "image") + .WithEnvironment(async ctx => + { + await Task.Delay(1); + ctx.EnvironmentVariables["ASYNC_KEY"] = "async-value"; + }) + .Resource; + + await builder.BuildAsync(); + + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new EnvironmentVariablesExecutionConfigurationGatherer(); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + + // Assert + Assert.Single(context.EnvironmentVariables); + Assert.Equal("async-value", context.EnvironmentVariables["ASYNC_KEY"]); + } + + #endregion + + #region CertificateTrustExecutionConfigurationGatherer Tests + + [Fact] + [RequiresCertificateStoreAccess] + public async Task CertificateTrustExecutionConfigurationGatherer_WithCertificateAuthorityCollection_SetsEnvironmentVariables() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var cert = CreateTestCertificate(); + var caCollection = builder.AddCertificateAuthorityCollection("test-ca").WithCertificate(cert); + + var resource = builder.AddContainer("test", "image") + .WithCertificateAuthorityCollection(caCollection) + .Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateCertificateTrustConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new CertificateTrustExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Contains("SSL_CERT_DIR", context.EnvironmentVariables.Keys); + var metadata = context.AdditionalConfigurationData.OfType().Single(); + Assert.Equal(CertificateTrustScope.Append, metadata.Scope); + Assert.NotEmpty(metadata.Certificates); + } + + [Fact] + [RequiresCertificateStoreAccess] + public async Task CertificateTrustExecutionConfigurationGatherer_WithSystemScope_IncludesSystemCertificates() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var cert = CreateTestCertificate(); + var caCollection = builder.AddCertificateAuthorityCollection("test-ca").WithCertificate(cert); + + var resource = builder.AddContainer("test", "image") + .WithCertificateAuthorityCollection(caCollection) + .WithCertificateTrustScope(CertificateTrustScope.System) + .Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateCertificateTrustConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new CertificateTrustExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Contains("SSL_CERT_FILE", context.EnvironmentVariables.Keys); + Assert.Contains("SSL_CERT_DIR", context.EnvironmentVariables.Keys); + var metadata = context.AdditionalConfigurationData.OfType().Single(); + Assert.Equal(CertificateTrustScope.System, metadata.Scope); + // System scope should include system root certificates + Assert.True(metadata.Certificates.Count > 1); + } + + [Fact] + [RequiresCertificateStoreAccess] + public async Task CertificateTrustExecutionConfigurationGatherer_WithOverrideScope_SetsCorrectEnvironmentVariables() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var cert = CreateTestCertificate(); + var caCollection = builder.AddCertificateAuthorityCollection("test-ca").WithCertificate(cert); + + var resource = builder.AddContainer("test", "image") + .WithCertificateAuthorityCollection(caCollection) + .WithCertificateTrustScope(CertificateTrustScope.Override) + .Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateCertificateTrustConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new CertificateTrustExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Contains("SSL_CERT_FILE", context.EnvironmentVariables.Keys); + Assert.Contains("SSL_CERT_DIR", context.EnvironmentVariables.Keys); + var metadata = context.AdditionalConfigurationData.OfType().Single(); + Assert.Equal(CertificateTrustScope.Override, metadata.Scope); + } + + [Fact] + [RequiresCertificateStoreAccess] + public async Task CertificateTrustExecutionConfigurationGatherer_WithNoneScope_DoesNotSetEnvironmentVariables() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var cert = CreateTestCertificate(); + var caCollection = builder.AddCertificateAuthorityCollection("test-ca").WithCertificate(cert); + + var resource = builder.AddContainer("test", "image") + .WithCertificateAuthorityCollection(caCollection) + .WithCertificateTrustScope(CertificateTrustScope.None) + .Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateCertificateTrustConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new CertificateTrustExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.DoesNotContain("SSL_CERT_FILE", context.EnvironmentVariables.Keys); + Assert.DoesNotContain("SSL_CERT_DIR", context.EnvironmentVariables.Keys); + var metadata = context.AdditionalConfigurationData.OfType().Single(); + Assert.Equal(CertificateTrustScope.None, metadata.Scope); + } + + [Fact] + public async Task CertificateTrustExecutionConfigurationGatherer_NoCertificateAnnotation_DoesNothing() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddContainer("test", "image").Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateCertificateTrustConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new CertificateTrustExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.DoesNotContain("SSL_CERT_FILE", context.EnvironmentVariables.Keys); + Assert.DoesNotContain("SSL_CERT_DIR", context.EnvironmentVariables.Keys); + } + + [Fact] + [RequiresCertificateStoreAccess] + public async Task CertificateTrustExecutionConfigurationGatherer_WithAppendScope_DoesNotSetSSL_CERT_FILE() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var cert = CreateTestCertificate(); + var caCollection = builder.AddCertificateAuthorityCollection("test-ca").WithCertificate(cert); + + var resource = builder.AddContainer("test", "image") + .WithCertificateAuthorityCollection(caCollection) + .WithCertificateTrustScope(CertificateTrustScope.Append) + .Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateCertificateTrustConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new CertificateTrustExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.DoesNotContain("SSL_CERT_FILE", context.EnvironmentVariables.Keys); + Assert.Contains("SSL_CERT_DIR", context.EnvironmentVariables.Keys); + } + + #endregion + + #region ServerAuthenticationCertificateExecutionConfigurationGatherer Tests + + [Fact] + [RequiresCertificateStoreAccess] + public async Task ServerAuthenticationCertificateExecutionConfigurationGatherer_WithCertificate_ConfiguresMetadata() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var cert = CreateTestCertificate(); + + var resource = builder.AddContainer("test", "image") + .WithAnnotation(new ServerAuthenticationCertificateAnnotation { Certificate = cert }) + .Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateServerAuthenticationCertificateConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new ServerAuthenticationCertificateExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + + // Assert + var metadata = context.AdditionalConfigurationData.OfType().Single(); + Assert.Equal(cert, metadata.Certificate); + Assert.NotNull(metadata.KeyPathReference); + Assert.NotNull(metadata.PfxPathReference); + } + + [Fact] + [RequiresCertificateStoreAccess] + public async Task ServerAuthenticationCertificateExecutionConfigurationGatherer_WithPassword_StoresPassword() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + builder.Configuration["Parameters:password"] = "test-password"; + var cert = CreateTestCertificate(); + var password = builder.AddParameter("password", secret: true); + + var resource = builder.AddContainer("test", "image") + .WithAnnotation(new ServerAuthenticationCertificateAnnotation + { + Certificate = cert, + Password = password.Resource + }) + .Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateServerAuthenticationCertificateConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new ServerAuthenticationCertificateExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + var metadata = context.AdditionalConfigurationData.OfType().Single(); + Assert.NotNull(metadata.Password); + } + + [Fact] + [RequiresCertificateStoreAccess] + public async Task ServerAuthenticationCertificateExecutionConfigurationGatherer_WithUseDeveloperCertificate_UsesDeveloperCert() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + + // Configure developer certificate service + var devCert = CreateTestCertificate(); + builder.Services.AddSingleton(new TestDeveloperCertificateService(devCert)); + + var resource = builder.AddContainer("test", "image") + .WithAnnotation(new ServerAuthenticationCertificateAnnotation + { + UseDeveloperCertificate = true + }) + .Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateServerAuthenticationCertificateConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new ServerAuthenticationCertificateExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + var metadata = context.AdditionalConfigurationData.OfType().Single(); + Assert.Equal(devCert, metadata.Certificate); + } + + [Fact] + public async Task ServerAuthenticationCertificateExecutionConfigurationGatherer_NoCertificateAnnotation_DoesNothing() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var resource = builder.AddContainer("test", "image").Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateServerAuthenticationCertificateConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new ServerAuthenticationCertificateExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + Assert.Empty(context.AdditionalConfigurationData.OfType()); + } + + [Fact] + [RequiresCertificateStoreAccess] + public async Task ServerAuthenticationCertificateExecutionConfigurationGatherer_TracksReferenceUsage() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var cert = CreateTestCertificate(); + + var resource = builder.AddContainer("test", "image") + .WithAnnotation(new ServerAuthenticationCertificateAnnotation { Certificate = cert }) + .Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateServerAuthenticationCertificateConfigurationContextFactory(); + var context = new ResourceExecutionConfigurationGathererContext(); + var gatherer = new ServerAuthenticationCertificateExecutionConfigurationGatherer(configContextFactory); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + // Assert + var metadata = context.AdditionalConfigurationData.OfType().Single(); + + // Initially, references should not be resolved + Assert.False(metadata.IsKeyPathReferenced); + Assert.False(metadata.IsPfxPathReferenced); + + // Accessing the references should mark them as resolved + _ = await metadata.KeyPathReference.GetValueAsync(CancellationToken.None); + Assert.True(metadata.IsKeyPathReferenced); + Assert.False(metadata.IsPfxPathReferenced); + + _ = await metadata.PfxPathReference.GetValueAsync(CancellationToken.None); + Assert.True(metadata.IsPfxPathReferenced); + } + + [Fact] + [RequiresCertificateStoreAccess] + public async Task ServerAuthenticationCertificateExecutionConfigurationGatherer_WithCallback_ExecutesCallback() + { + // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + var cert = CreateTestCertificate(); + var callbackExecuted = false; + + var resource = builder.AddContainer("test", "image") + .WithAnnotation(new ServerAuthenticationCertificateAnnotation { Certificate = cert }) + .WithAnnotation(new ServerAuthenticationCertificateConfigurationCallbackAnnotation(ctx => + { + callbackExecuted = true; + return Task.CompletedTask; + })) + .Resource; + + await builder.BuildAsync(); + + var configContextFactory = CreateServerAuthenticationCertificateConfigurationContextFactory(); + var gatherer = new ServerAuthenticationCertificateExecutionConfigurationGatherer(configContextFactory); + var context = new ResourceExecutionConfigurationGathererContext(); + + // Act + await gatherer.GatherAsync(context, resource, NullLogger.Instance, builder.ExecutionContext); + + // Assert + Assert.True(callbackExecuted); + } + + #endregion + + #region Helper Methods + + private static X509Certificate2 CreateTestCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + new X500DistinguishedName("CN=test"), + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return request.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); + } + + private static Func CreateCertificateTrustConfigurationContextFactory() + { + return scope => new CertificateTrustExecutionConfigurationContext + { + CertificateBundlePath = ReferenceExpression.Create($"/etc/ssl/certs/ca-bundle.crt"), + CertificateDirectoriesPath = ReferenceExpression.Create($"/etc/ssl/certs") + }; + } + + private static Func CreateServerAuthenticationCertificateConfigurationContextFactory() + { + return cert => new ServerAuthenticationCertificateExecutionConfigurationContext + { + CertificatePath = ReferenceExpression.Create($"/etc/ssl/certs/server.crt"), + KeyPath = ReferenceExpression.Create($"/etc/ssl/private/server.key"), + PfxPath = ReferenceExpression.Create($"/etc/ssl/certs/server.pfx") + }; + } + + private sealed class TestDeveloperCertificateService : IDeveloperCertificateService + { + private readonly X509Certificate2? _certificate; + + public TestDeveloperCertificateService(X509Certificate2? certificate = null) + { + _certificate = certificate; + } + + public ImmutableList Certificates => + _certificate != null ? [_certificate] : ImmutableList.Empty; + + public bool SupportsContainerTrust => true; + + public bool TrustCertificate => true; + + public bool UseForServerAuthentication => true; + } + + #endregion +} diff --git a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs index 50d56778428..39b3bd26c9f 100644 --- a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs @@ -178,7 +178,9 @@ public async Task GetEnvironmentVariableValuesAsyncReturnCorrectVariablesInRunMo context.EnvironmentVariables["ELASTIC_PASSWORD"] = "p@ssw0rd1"; }); +#pragma warning disable CS0618 // Type or member is obsolete var env = await container.Resource.GetEnvironmentVariableValuesAsync().DefaultTimeout(); +#pragma warning restore CS0618 // Type or member is obsolete Assert.Collection(env, env => @@ -211,7 +213,9 @@ public async Task GetEnvironmentVariableValuesAsyncReturnCorrectVariablesUsingVa .WithEnvironment("xpack.security.enabled", "true") .WithEnvironment("ELASTIC_PASSWORD", passwordParameter); +#pragma warning disable CS0618 // Type or member is obsolete var env = await container.Resource.GetEnvironmentVariableValuesAsync().DefaultTimeout(); +#pragma warning restore CS0618 // Type or member is obsolete Assert.Collection(env, env => @@ -244,7 +248,9 @@ public async Task GetEnvironmentVariableValuesAsyncReturnCorrectVariablesUsingMa .WithEnvironment("xpack.security.enabled", "true") .WithEnvironment("ELASTIC_PASSWORD", passwordParameter); +#pragma warning disable CS0618 // Type or member is obsolete var env = await container.Resource.GetEnvironmentVariableValuesAsync(DistributedApplicationOperation.Publish).DefaultTimeout(); +#pragma warning restore CS0618 // Type or member is obsolete Assert.Collection(env, env => @@ -267,6 +273,7 @@ public async Task GetEnvironmentVariableValuesAsyncReturnCorrectVariablesUsingMa [Fact] public async Task GetArgumentValuesAsync_ReturnsCorrectValuesForSpecialCases() { +#pragma warning disable CS0618 // Type or member is obsolete var builder = DistributedApplication.CreateBuilder(); var surrogate = builder.AddResource(new ConnectionStringParameterResource("ResourceWithConnectionStringSurrogate", _ => "ConnectionString", null)); var secretParameter = builder.AddResource(new ParameterResource("SecretParameter", _ => "SecretParameter", true)); @@ -291,6 +298,7 @@ public async Task GetArgumentValuesAsync_ReturnsCorrectValuesForSpecialCases() .Resource.GetArgumentValuesAsync().DefaultTimeout(); Assert.Equal>(["ConnectionString", "SecretParameter", "NonSecretParameter"], executableArgs); +#pragma warning restore CS0618 // Type or member is obsolete } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Utils/ArgumentEvaluator.cs b/tests/Aspire.Hosting.Tests/Utils/ArgumentEvaluator.cs index c61c3fa15a1..044a302a638 100644 --- a/tests/Aspire.Hosting.Tests/Utils/ArgumentEvaluator.cs +++ b/tests/Aspire.Hosting.Tests/Utils/ArgumentEvaluator.cs @@ -10,27 +10,22 @@ public sealed class ArgumentEvaluator { public static async ValueTask> GetArgumentListAsync(IResource resource, IServiceProvider? serviceProvider = null) { - var args = new List(); - - await resource.ProcessArgumentValuesAsync( - new(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) - { - ServiceProvider = serviceProvider - }), - (_, processed, ex, _) => + var executionContext = new DistributedApplicationExecutionContext( + new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { - if (ex is not null) - { - ExceptionDispatchInfo.Throw(ex); - } + ServiceProvider = serviceProvider, + }); + + (var executionConfiguration, var exception) = await resource.ExecutionConfigurationBuilder() + .WithArguments() + .BuildAsync(executionContext, NullLogger.Instance, CancellationToken.None) + .ConfigureAwait(false); - if (processed is string s) - { - args.Add(s); - } - }, - NullLogger.Instance); + if (exception is not null) + { + ExceptionDispatchInfo.Throw(exception); + } - return args; + return executionConfiguration.Arguments.Select(a => a.Value).ToList(); } } diff --git a/tests/Aspire.Hosting.Tests/Utils/EnvironmentVariableEvaluator.cs b/tests/Aspire.Hosting.Tests/Utils/EnvironmentVariableEvaluator.cs index 7eef30da6df..efc18ee28e2 100644 --- a/tests/Aspire.Hosting.Tests/Utils/EnvironmentVariableEvaluator.cs +++ b/tests/Aspire.Hosting.Tests/Utils/EnvironmentVariableEvaluator.cs @@ -18,24 +18,15 @@ public static async ValueTask> GetEnvironmentVariable ServiceProvider = serviceProvider }); - var environmentVariables = new Dictionary(); - await resource.ProcessEnvironmentVariableValuesAsync( - executionContext, - (key, unprocessed, value, ex) => - { - if (ex is not null) - { - ExceptionDispatchInfo.Throw(ex); - } + (var executionConfiguration, var exception) = await resource.ExecutionConfigurationBuilder() + .WithEnvironmentVariables() + .BuildAsync(executionContext, NullLogger.Instance, CancellationToken.None); - if (value is string s) - { - environmentVariables[key] = s; - } - }, - NullLogger.Instance, - CancellationToken.None); + if (exception is not null) + { + ExceptionDispatchInfo.Throw(exception); + } - return environmentVariables; + return executionConfiguration.EnvironmentVariables.ToDictionary(); } } diff --git a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs index 41d07b54c07..dd40af66a63 100644 --- a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs +++ b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs @@ -160,13 +160,14 @@ 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 )).DefaultTimeout(); - Assert.Equal("Parameter resource could not be used because configuration key 'Parameters:parameter' is missing and the Parameter has no default value.", exception.Message); + var innerException = Assert.IsType(exception.InnerException); + Assert.Equal("Parameter resource could not be used because configuration key 'Parameters:parameter' is missing and the Parameter has no default value.", innerException.Message); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs index 22d549dc8fa..e6109acb2ed 100644 --- a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs @@ -88,7 +88,7 @@ public async Task ResourceWithEndpointRespectsCustomEnvironmentVariableNaming(Re break; } } - + [Fact] public async Task ResourceWithConflictingEndpointsProducesFullyScopedEnvironmentVariables() { @@ -214,10 +214,13 @@ public async Task ConnectionStringResourceThrowsWhenMissingConnectionString() var projectB = builder.AddProject("projectb").WithReference(resource, optional: false); // Call environment variable callbacks. - await Assert.ThrowsAsync(async () => + var aggregate = await Assert.ThrowsAsync(async () => { await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); }).DefaultTimeout(); + + var inner = Assert.IsType(aggregate.InnerException); + Assert.Equal("The connection string for the resource 'resource' is not available.", inner.Message); } [Fact] @@ -247,12 +250,13 @@ public async Task ParameterAsConnectionStringResourceThrowsWhenConnectionStringS .WithReference(missingResource); // Call environment variable callbacks. - var exception = await Assert.ThrowsAsync(async () => + var aggregate = await Assert.ThrowsAsync(async () => { - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); }).DefaultTimeout(); - Assert.Equal("Connection string parameter resource could not be used because connection string 'missingresource' is missing.", exception.Message); + var inner = Assert.IsType(aggregate.InnerException); + Assert.Equal("Connection string parameter resource could not be used because connection string 'missingresource' is missing.", inner.Message); } [Fact]