diff --git a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs index 2b58aec3f1c..73caa78d9a2 100644 --- a/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs +++ b/src/Aspire.Hosting.JavaScript/JavaScriptHostingExtensions.cs @@ -223,7 +223,20 @@ private static IResourceBuilder WithNodeDefaults(this IRes } else { - ctx.Arguments.Add("--use-openssl-ca"); + if (ctx.EnvironmentVariables.TryGetValue("NODE_OPTIONS", out var existingOptionsObj)) + { + ctx.EnvironmentVariables["NODE_OPTIONS"] = existingOptionsObj switch + { + // Attempt to append to existing NODE_OPTIONS if possible, otherwise overwrite + string s when !string.IsNullOrEmpty(s) => $"{s} --use-openssl-ca", + ReferenceExpression re => ReferenceExpression.Create($"{re} --use-openssl-ca"), + _ => "--use-openssl-ca", + }; + } + else + { + ctx.EnvironmentVariables["NODE_OPTIONS"] = "--use-openssl-ca"; + } } return Task.CompletedTask; diff --git a/src/Aspire.Hosting.Yarp/YarpResourceExtensions.cs b/src/Aspire.Hosting.Yarp/YarpResourceExtensions.cs index 6fa479c1f56..6e4c8b70ad2 100644 --- a/src/Aspire.Hosting.Yarp/YarpResourceExtensions.cs +++ b/src/Aspire.Hosting.Yarp/YarpResourceExtensions.cs @@ -45,7 +45,10 @@ public static IResourceBuilder AddYarp( { ctx.EnvironmentVariables["Kestrel__Certificates__Default__Path"] = ctx.CertificatePath; ctx.EnvironmentVariables["Kestrel__Certificates__Default__KeyPath"] = ctx.KeyPath; - ctx.EnvironmentVariables["Kestrel__Certificates__Default__Password"] = ctx.Password; + if (ctx.Password is not null) + { + ctx.EnvironmentVariables["Kestrel__Certificates__Default__Password"] = ctx.Password; + } return Task.CompletedTask; }); diff --git a/src/Aspire.Hosting/ApplicationModel/CertificateKeyPairConfigurationCallbackAnnotaion.cs b/src/Aspire.Hosting/ApplicationModel/CertificateKeyPairConfigurationCallbackAnnotaion.cs index 372c6174307..53cf266023e 100644 --- a/src/Aspire.Hosting/ApplicationModel/CertificateKeyPairConfigurationCallbackAnnotaion.cs +++ b/src/Aspire.Hosting/ApplicationModel/CertificateKeyPairConfigurationCallbackAnnotaion.cs @@ -73,7 +73,7 @@ public sealed class CertificateKeyPairConfigurationCallbackAnnotationContext /// /// /// - public required Dictionary EnvironmentVariables { get; init; } + public required Dictionary EnvironmentVariables { get; init; } /// /// A value provider that will resolve to a path to the certificate file. diff --git a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs index b56e1f22c25..fd859936d06 100644 --- a/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs +++ b/src/Aspire.Hosting/ApplicationModel/ResourceExtensions.cs @@ -3,11 +3,14 @@ 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 + namespace Aspire.Hosting.ApplicationModel; /// @@ -260,6 +263,76 @@ await resource.ProcessArgumentValuesAsync( return [.. args]; } + /// + /// Gather argument values, but do not resolve them. Used to allow multiple callbacks to constructively contribute to + /// the argument list before resolving. + /// + /// The resource to retrieve argument values for. + /// The execution context used during the retrieval of argument values. + /// 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. + internal static async ValueTask> GatherArgumentValuesAsync( + this IResource resource, + DistributedApplicationExecutionContext executionContext, + ILogger logger, + CancellationToken cancellationToken = default) + { + var args = new List(); + if (resource.TryGetAnnotationsOfType(out var callbacks)) + { + var context = new CommandLineArgsCallbackContext(args, resource, cancellationToken) + { + Logger = logger, + ExecutionContext = executionContext + }; + + foreach (var callback in callbacks) + { + await callback.Callback(context).ConfigureAwait(false); + } + } + + return args; + } + + /// + /// Processes pre-gathered command-line argument values for the specified resource in the given execution context. + /// + /// The resource for which the argument values are being processed. + /// The execution context used during the processing of argument values. + /// The list of pre-gathered argument values to process. + /// A callback invoked for each argument value, providing the unprocessed value, processed string representation, any exception, and a sensitivity flag. + /// 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. + internal static async ValueTask ProcessGatheredArgumentValuesAsync( + this IResource resource, + DistributedApplicationExecutionContext executionContext, + List arguments, + // (unprocessed, processed, exception, isSensitive) + Action processValue, + ILogger logger, + CancellationToken cancellationToken = default) + { + foreach (var a in arguments) + { + try + { + var resolvedValue = await resource.ResolveValueAsync(executionContext, logger, a, null, cancellationToken).ConfigureAwait(false); + + if (resolvedValue?.Value != null) + { + processValue(a, resolvedValue.Value, null, resolvedValue.IsSensitive); + } + } + catch (Exception ex) + { + processValue(a, a.ToString(), ex, false); + } + } + } + /// /// Processes argument values for the specified resource in the given execution context. /// @@ -280,36 +353,76 @@ public static async ValueTask ProcessArgumentValuesAsync( ILogger logger, CancellationToken cancellationToken = default) { - if (resource.TryGetAnnotationsOfType(out var callbacks)) + var args = await GatherArgumentValuesAsync(resource, executionContext, logger, cancellationToken).ConfigureAwait(false); + + await ProcessGatheredArgumentValuesAsync(resource, executionContext, args, processValue, logger, cancellationToken).ConfigureAwait(false); + } + + /// + /// Gather environment variable values, but do not resolve them. Used to allow multiple callbacks to + /// contribute to the environment variable list before resolving. + /// + /// The resource containing the environment variables to gather. + /// The execution context used during the gathering of environment variables. + /// 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. + internal static async ValueTask> GatherEnvironmentVariableValuesAsync( + this IResource resource, + DistributedApplicationExecutionContext executionContext, + ILogger logger, + CancellationToken cancellationToken = default) + { + var config = new Dictionary(); + if (resource.TryGetEnvironmentVariables(out var callbacks)) { - var args = new List(); - var context = new CommandLineArgsCallbackContext(args, resource, cancellationToken) + var context = new EnvironmentCallbackContext(executionContext, resource, config, cancellationToken) { - Logger = logger, - ExecutionContext = executionContext + Logger = logger }; foreach (var callback in callbacks) { await callback.Callback(context).ConfigureAwait(false); } + } - foreach (var a in args) + return config; + } + + /// + /// Processes pre-gathered environment variable values for the specified resource within the given execution context. + /// + /// The resource for which the environment variables are being processed. + /// The execution context used during the processing of environment variables. + /// The pre-gathered environment variable values to be processed. + /// An action delegate invoked for each environment variable, providing the key, the unprocessed value, the processed value (if available), and any exception encountered during processing. + /// 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. + internal static async ValueTask ProcessGatheredEnvironmentVariableValuesAsync( + this IResource resource, + DistributedApplicationExecutionContext executionContext, + Dictionary environmentVariables, + Action processValue, + ILogger logger, + CancellationToken cancellationToken = default) + { + foreach (var (key, expr) in environmentVariables) + { + try { - try - { - var resolvedValue = await resource.ResolveValueAsync(executionContext, logger, a, null, cancellationToken).ConfigureAwait(false); + var resolvedValue = await resource.ResolveValueAsync(executionContext, logger, expr, key, cancellationToken).ConfigureAwait(false); - if (resolvedValue?.Value != null) - { - processValue(a, resolvedValue.Value, null, resolvedValue.IsSensitive); - } - } - catch (Exception ex) + if (resolvedValue?.Value is not null) { - processValue(a, a.ToString(), ex, false); + processValue(key, expr, resolvedValue.Value, null); } } + catch (Exception ex) + { + processValue(key, expr, expr?.ToString(), ex); + } } } @@ -329,50 +442,204 @@ public static async ValueTask ProcessEnvironmentVariableValuesAsync( ILogger logger, CancellationToken cancellationToken = default) { - if (resource.TryGetEnvironmentVariables(out var callbacks)) + var config = await GatherEnvironmentVariableValuesAsync(resource, executionContext, logger, cancellationToken).ConfigureAwait(false); + + await ProcessGatheredEnvironmentVariableValuesAsync(resource, executionContext, config, processValue, logger, cancellationToken).ConfigureAwait(false); + } + + internal static NetworkIdentifier GetDefaultResourceNetwork(this IResource resource) + { + return resource.IsContainer() ? KnownNetworkIdentifiers.DefaultAspireContainerNetwork : KnownNetworkIdentifiers.LocalhostNetwork; + } + + internal static IEnumerable GetSupportedNetworks(this IResource resource) + { + 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 X509Certificate2? ServerAuthCertificate { get; init; } + + /// + /// The password for the server authentication certificate, if any. + /// + public string? ServerAuthPassword { 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) { - var config = new Dictionary(); - var context = new EnvironmentCallbackContext(executionContext, resource, config, cancellationToken) - { - Logger = logger - }; + ArgumentNullException.ThrowIfNull(certificateTrustConfigContextFactory); + } - foreach (var callback in callbacks) + 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); + } + + X509Certificate2? serverAuthCertificate = null; + string? serverAuthPassword = null; + if (withServerAuthCertificateConfig) + { + (args, envVars, serverAuthCertificate, serverAuthPassword) = 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) => { - await callback.Callback(context).ConfigureAwait(false); - } + 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); - foreach (var (key, expr) in config) + await ProcessGatheredEnvironmentVariableValuesAsync( + resource, + executionContext, + envVars, + (key, unprocessed, processed, ex) => { - try + if (ex is not null) { - var resolvedValue = await resource.ResolveValueAsync(executionContext, logger, expr, key, cancellationToken).ConfigureAwait(false); + exceptions.Add(ex); - if (resolvedValue?.Value is not null) - { - processValue(key, expr, resolvedValue.Value, null); - } + resourceLogger.LogCritical(ex, "Failed to apply environment variable '{EnvVarKey}'. A dependency may have failed to start.", key); } - catch (Exception ex) + else if (processed is string s) { - processValue(key, expr, expr?.ToString(), ex); + 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); } - } - internal static NetworkIdentifier GetDefaultResourceNetwork(this IResource resource) - { - return resource.IsContainer() ? KnownNetworkIdentifiers.DefaultAspireContainerNetwork : KnownNetworkIdentifiers.LocalhostNetwork; + return new ResourceConfigurationContext + { + Arguments = resolvedArgs, + EnvironmentVariables = resolvedEnvVars, + CertificateTrustScope = certificateTrustScope, + TrustedCertificates = trustedCertificates!, + ServerAuthCertificate = serverAuthCertificate, + ServerAuthPassword = serverAuthPassword, + Exception = exception, + }; } - internal static IEnumerable GetSupportedNetworks(this IResource resource) + /// + /// Context for building certificate trust configuration paths. + /// + internal class CertificateTrustConfigBuilderContext { - return resource.IsContainer() ? [KnownNetworkIdentifiers.DefaultAspireContainerNetwork, KnownNetworkIdentifiers.LocalhostNetwork] : [KnownNetworkIdentifiers.LocalhostNetwork]; + /// + /// 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; } } /// - /// Processes trusted certificates configuration for the specified resource within the given execution context. + /// 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 /// @@ -380,28 +647,22 @@ internal static IEnumerable GetSupportedNetworks(this IResour /// /// The resource for which to process the certificate trust configuration. /// The execution context used during the processing. - /// A function that processes argument values. - /// A function that processes environment variable values. + /// 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 path to a custom certificate bundle for the resource. - /// A function that takes the active and returns a representing path(s) to a directory containing the custom certificates for the resource. + /// 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<(CertificateTrustScope, X509Certificate2Collection?)> ProcessCertificateTrustConfigAsync( + internal static async ValueTask<(List, Dictionary, CertificateTrustScope, X509Certificate2Collection)> GatherCertificateTrustConfigAsync( this IResource resource, DistributedApplicationExecutionContext executionContext, - // (unprocessed, processed, exception, isSensitive) - Action processArgumentValue, - // (key, unprocessed, processed, exception) - Action processEnvironmentVariableValue, + List arguments, + Dictionary environmentVariables, ILogger logger, - Func bundlePathFactory, - Func certificateDirectoryPathsFactory, + Func configContextFactory, CancellationToken cancellationToken = default) { -#pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var developerCertificateService = executionContext.ServiceProvider.GetRequiredService(); -#pragma warning restore ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var trustDevCert = developerCertificateService.TrustCertificate; var certificates = new X509Certificate2Collection(); @@ -419,7 +680,7 @@ internal static IEnumerable GetSupportedNetworks(this IResour if (scope == CertificateTrustScope.None) { - return (scope, null); + return (arguments, environmentVariables, scope, new X509Certificate2Collection()); } if (scope == CertificateTrustScope.System) @@ -439,21 +700,17 @@ internal static IEnumerable GetSupportedNetworks(this IResour if (!certificates.Any()) { logger.LogInformation("No custom certificate authorities to configure for '{ResourceName}'. Default certificate authority trust behavior will be used.", resource.Name); - return (scope, null); + return (arguments, environmentVariables, scope, new X509Certificate2Collection()); } - var bundlePath = bundlePathFactory(scope); - var certificateDirectoryPaths = certificateDirectoryPathsFactory(scope); + var configBuilderContext = configContextFactory(scope); // Apply default OpenSSL environment configuration for certificate trust - var environment = new Dictionary() - { - { "SSL_CERT_DIR", certificateDirectoryPaths }, - }; + environmentVariables["SSL_CERT_DIR"] = configBuilderContext.CertificateDirectoriesPath; if (scope != CertificateTrustScope.Append) { - environment["SSL_CERT_FILE"] = bundlePath; + environmentVariables["SSL_CERT_FILE"] = configBuilderContext.CertificateBundlePath; } var context = new CertificateTrustConfigurationCallbackAnnotationContext @@ -461,10 +718,10 @@ internal static IEnumerable GetSupportedNetworks(this IResour ExecutionContext = executionContext, Resource = resource, Scope = scope, - CertificateBundlePath = bundlePath, - CertificateDirectoriesPath = certificateDirectoryPaths, - Arguments = new(), - EnvironmentVariables = environment, + CertificateBundlePath = configBuilderContext.CertificateBundlePath, + CertificateDirectoriesPath = configBuilderContext.CertificateDirectoriesPath, + Arguments = arguments, + EnvironmentVariables = environmentVariables, CancellationToken = cancellationToken, }; @@ -476,52 +733,93 @@ internal static IEnumerable GetSupportedNetworks(this IResour } } - if (!context.Arguments.Any() && !context.EnvironmentVariables.Any()) + if (scope == CertificateTrustScope.System) { - logger.LogInformation("No certificate trust configuration was provided for '{ResourceName}'. Default certificate authority trust behavior will be used.", resource.Name); - return (scope, null); + 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)); } - if (scope == CertificateTrustScope.System) + 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; } + } + + /// + /// 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 certificate and passphrase for server auth. + internal static async ValueTask<(List arguments, Dictionary environmentVariables, X509Certificate2? serverAuthCertificate, string? passphrase)> GatherServerAuthCertificateConfigAsync( + this IResource resource, + DistributedApplicationExecutionContext executionContext, + List arguments, + Dictionary environmentVariables, + Func certificateConfigContextFactory, + CancellationToken cancellationToken = default) + { + var effectiveAnnotation = new CertificateKeyPairAnnotation(); + if (resource.TryGetLastAnnotation(out var annotation)) { - 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)); + effectiveAnnotation = annotation; } - foreach (var a in context.Arguments) + if (effectiveAnnotation is null) { - try - { - var resolvedValue = await resource.ResolveValueAsync(executionContext, logger, a, null, cancellationToken).ConfigureAwait(false); + // Should never happen + return (arguments, environmentVariables, null, null); + } - if (resolvedValue?.Value != null) - { - processArgumentValue(a, resolvedValue.Value, null, resolvedValue.IsSensitive); - } - } - catch (Exception ex) + X509Certificate2? certificate = effectiveAnnotation.Certificate; + if (certificate is null) + { + var developerCertificateService = executionContext.ServiceProvider.GetRequiredService(); + if (effectiveAnnotation.UseDeveloperCertificate.GetValueOrDefault(developerCertificateService.DefaultTlsTerminationEnabled)) { - processArgumentValue(a, a.ToString(), ex, false); + certificate = developerCertificateService.Certificates.FirstOrDefault(); } } - foreach (var (key, expr) in context.EnvironmentVariables) + if (certificate is null) { - try - { - var resolvedValue = await resource.ResolveValueAsync(executionContext, logger, expr, key, cancellationToken).ConfigureAwait(false); + // No certificate to configure, do nothing + return (arguments, environmentVariables, null, null); + } - if (resolvedValue?.Value is not null) - { - processEnvironmentVariableValue(key, expr, resolvedValue.Value, null); - } - } - catch (Exception ex) - { - processEnvironmentVariableValue(key, expr, expr?.ToString(), ex); - } + var configBuilderContext = certificateConfigContextFactory(certificate); + + var context = new CertificateKeyPairConfigurationCallbackAnnotationContext + { + 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); } - return (scope, certificates); + string? password = effectiveAnnotation.Password is not null ? await effectiveAnnotation.Password.GetValueAsync(cancellationToken).ConfigureAwait(false) : null; + + return (arguments, environmentVariables, certificate, password); } internal static async ValueTask ResolveValueAsync( diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index cc6ece7b71d..9c233dac097 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1534,20 +1534,71 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou spec.Args.AddRange(projectArgs); } - // Get args from app host model resource. - (var appHostArgs, var failedToApplyArgs) = await BuildArgsAsync(resourceLogger, er.ModelResource, cancellationToken).ConfigureAwait(false); + // Build the base paths for certificate output in the DCP session directory. + var certificatesRootDir = Path.Join(_locations.DcpSessionDir, exe.Name()); + var bundleOutputPath = Path.Join(certificatesRootDir, "cert.pem"); + var certificatesOutputPath = Path.Join(certificatesRootDir, "certs"); + var baseServerAuthOutputPath = Path.Join(certificatesRootDir, "private"); - // Build environment variables - (var env, var failedToApplyConfiguration) = await BuildEnvVarsAsync(resourceLogger, er.ModelResource, cancellationToken).ConfigureAwait(false); + // 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() + { + 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); + + if (configContext.ServerAuthCertificate is not null) + { + (var certificatePem, var keyPem) = GetCertificateKeyPair(configContext.ServerAuthCertificate, configContext.ServerAuthPassword); + var pfxBytes = configContext.ServerAuthCertificate.Export(X509ContentType.Pfx, configContext.ServerAuthPassword); - // Build certificate trust configuration (args and env vars) - (var certificateArgs, var certificateEnv, var failedToApplyCertificateConfig) = await BuildExecutableCertificateTrustConfigAsync(resourceLogger, er.ModelResource, cancellationToken).ConfigureAwait(false); + var certificateBytes = Encoding.ASCII.GetBytes(certificatePem); + var keyBytes = Encoding.ASCII.GetBytes(keyPem); - (var keyPairArgs, var keyPairEnv, var failedToApplyKeyPairConfig) = await BuildExecutableCertificateKeyPairAsync(resourceLogger, er.ModelResource, cancellationToken).ConfigureAwait(false); + Directory.CreateDirectory(baseServerAuthOutputPath); - appHostArgs.AddRange(certificateArgs); - appHostArgs.AddRange(keyPairArgs); - var launchArgs = BuildLaunchArgs(er, spec, appHostArgs); + // Write each of the certificate, key, and PFX assets to the temp folder + File.WriteAllBytes(Path.Join(baseServerAuthOutputPath, $"{configContext.ServerAuthCertificate.Thumbprint}.key"), keyBytes); + File.WriteAllBytes(Path.Join(baseServerAuthOutputPath, $"{configContext.ServerAuthCertificate.Thumbprint}.crt"), certificateBytes); + File.WriteAllBytes(Path.Join(baseServerAuthOutputPath, $"{configContext.ServerAuthCertificate.Thumbprint}.pfx"), pfxBytes); + + Array.Clear(keyPem, 0, keyPem.Length); + Array.Clear(keyBytes, 0, keyBytes.Length); + } + + // 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 executableArgs = launchArgs.Where(a => !a.AnnotationOnly).Select(a => a.Value).ToList(); if (executableArgs.Count > 0) { @@ -1557,12 +1608,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))); - env.AddRange(certificateEnv); - env.AddRange(keyPairEnv); + spec.Env = configContext.EnvironmentVariables; - spec.Env = env; - - if (failedToApplyConfiguration || failedToApplyArgs || failedToApplyCertificateConfig || failedToApplyKeyPairConfig) + if (configContext.Exception is not null) { throw new FailedToApplyEnvironmentException(); } @@ -1779,39 +1827,141 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour (spec.RunArgs, var failedToApplyRunArgs) = await BuildRunArgsAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false); - // Build the arguments to pass to the container entrypoint - (var args, var failedToApplyArgs) = await BuildArgsAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false); + var certificatesDestination = ContainerCertificatePathsAnnotation.DefaultCustomCertificatesDestination; + var bundlePaths = ContainerCertificatePathsAnnotation.DefaultCertificateBundlePaths.ToList(); + var certificateDirsPaths = ContainerCertificatePathsAnnotation.DefaultCertificateDirectoriesPaths.ToList(); + + if (cr.ModelResource.TryGetLastAnnotation(out var pathsAnnotation)) + { + certificatesDestination = pathsAnnotation.CustomCertificatesDestination ?? certificatesDestination; + bundlePaths = pathsAnnotation.DefaultCertificateBundles ?? bundlePaths; + certificateDirsPaths = pathsAnnotation.DefaultCertificateDirectories ?? certificateDirsPaths; + } + + 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 dirs = new List { certificatesDestination + "/certs" }; + if (scope == CertificateTrustScope.Append) + { + // When appending to the default trust store, include the default certificate directories + dirs.AddRange(certificateDirsPaths!); + } + + return new() + { + CertificateBundlePath = ReferenceExpression.Create($"{certificatesDestination}/cert.pem"), + // Build Linux PATH style colon-separated list of directories + CertificateDirectoriesPath = ReferenceExpression.Create($"{string.Join(':', dirs)}"), + }; + }, + serverAuthCertificateConfigContextFactory: (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); - // Build the environment variables to apply to the container - (var env, var failedToApplyConfiguration) = await BuildEnvVarsAsync(resourceLogger, modelContainerResource, 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) + { + pemCertificates = new ContainerPemCertificates + { + Certificates = configContext.TrustedCertificates.Select(c => + { + return new PemCertificate + { + Thumbprint = c.Thumbprint, + Contents = c.ExportCertificatePem(), + }; + }).DistinctBy(cert => cert.Thumbprint).ToList(), + Destination = certificatesDestination, + ContinueOnError = true, + }; + + if (configContext.CertificateTrustScope != 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. + // Group by common directory to avoid creating multiple file system entries for the same root directory. + pemCertificates.OverwriteBundlePaths = bundlePaths; + } + } + + spec.PemCertificates = pemCertificates; // Build files that need to be created inside the container var createFiles = await BuildCreateFilesAsync(modelContainerResource, cancellationToken).ConfigureAwait(false); - // Build certificate specific arguments, environment variables, and files - (var certificateArgs, var certificateEnv, var certificateFiles, var failedToApplyCertificateConfig) = await BuildContainerCertificateAuthorityTrustAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false); + if (configContext.ServerAuthCertificate is not null) + { + (var certificatePem, var keyPem) = GetCertificateKeyPair(configContext.ServerAuthCertificate, configContext.ServerAuthPassword); + var pfxBytes = configContext.ServerAuthCertificate.Export(X509ContentType.Pfx, configContext.ServerAuthPassword); - (var keyPairArgs, var keyPairEnv, var keyPairFiles, var failedToApplyKeyPairConfig) = await BuildContainerCertificateKeyPairAsync(resourceLogger, modelContainerResource, cancellationToken).ConfigureAwait(false); + var baseOutputPath = Path.Join(_locations.DcpSessionDir, dcpContainerResource.Name(), "private"); + if (spec.Persistent == true) + { + // Need to write the pfx cert to a persistent location so it won't trigger a lifecycle key invalidation + baseOutputPath = Path.Join(Path.GetTempPath(), "aspire", _configuration["AppHost:Sha256"], spec.ContainerName, "private"); + } - args.AddRange(certificateArgs); - args.AddRange(keyPairArgs); - env.AddRange(certificateEnv); - env.AddRange(keyPairEnv); - createFiles.AddRange(certificateFiles); - createFiles.AddRange(keyPairFiles); + // The PFX file is binary, so we need to write it to a temp file first + var pfxOutputPath = Path.Join(baseOutputPath, $"{configContext.ServerAuthCertificate.Thumbprint}.pfx"); + Directory.CreateDirectory(baseOutputPath); + File.WriteAllBytes(pfxOutputPath, pfxBytes); + + // Write the certificate and key to the container filesystem + createFiles.Add(new ContainerCreateFileSystem + { + Destination = serverAuthCertificatesBasePath, + Entries = [ + new ContainerFileSystemEntry + { + Name = configContext.ServerAuthCertificate.Thumbprint + ".key", + Type = ContainerFileSystemEntryType.File, + Contents = new string(keyPem), + }, + new ContainerFileSystemEntry + { + Name = configContext.ServerAuthCertificate.Thumbprint + ".crt", + Type = ContainerFileSystemEntryType.File, + Contents = new string(certificatePem), + }, + // Copy the PFX file from the temp location + new ContainerFileSystemEntry + { + Name = configContext.ServerAuthCertificate.Thumbprint + ".pfx", + Type = ContainerFileSystemEntryType.File, + Source = pfxOutputPath, + }, + ], + }); + + Array.Clear(keyPem, 0, keyPem.Length); + Array.Clear(pfxBytes, 0, pfxBytes.Length); + } + // Set the final args, env vars, and create files on the container spec + var args = configContext.Arguments.Select(a => a.Value); // Set the final args, env vars, and create files on the container spec if (modelContainerResource is ContainerResource { ShellExecution: true }) { - spec.Args = ["-c", $"{string.Join(' ', args.Select(a => a.Value))}"]; + spec.Args = ["-c", $"{string.Join(' ', args)}"]; } else { - spec.Args = args.Select(a => a.Value).ToList(); + spec.Args = args.ToList(); } - - dcpContainerResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, args.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive))); - spec.Env = env; + dcpContainerResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, configContext.Arguments.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive))); + spec.Env = configContext.EnvironmentVariables; spec.CreateFiles = createFiles; if (modelContainerResource is ContainerResource containerResource) @@ -1819,7 +1969,7 @@ private async Task CreateContainerAsync(RenderedModelResource cr, ILogger resour spec.Command = containerResource.Entrypoint; } - if (failedToApplyRunArgs || failedToApplyArgs || failedToApplyConfiguration || failedToApplyCertificateConfig || failedToApplyKeyPairConfig) + if (failedToApplyRunArgs || configContext.Exception is not null) { throw new FailedToApplyEnvironmentException(); } @@ -2197,33 +2347,6 @@ async Task EnsureResourceDeletedAsync(string resourceName) where T : CustomRe } } - private async Task<(List<(string Value, bool IsSensitive)>, bool)> BuildArgsAsync(ILogger resourceLogger, IResource modelResource, CancellationToken cancellationToken) - { - var failedToApplyArgs = false; - var args = new List<(string Value, bool IsSensitive)>(); - - await modelResource.ProcessArgumentValuesAsync( - _executionContext, - (unprocessed, value, ex, isSensitive) => - { - if (ex is not null) - { - failedToApplyArgs = true; - - resourceLogger.LogCritical(ex, "Failed to apply argument value '{ArgKey}'. A dependency may have failed to start.", ex.Data["ArgKey"]); - _logger.LogDebug(ex, "Failed to apply argument value '{ArgKey}' to '{ResourceName}'. A dependency may have failed to start.", ex.Data["ArgKey"], modelResource.Name); - } - else if (value is { } argument) - { - args.Add((argument, isSensitive)); - } - }, - resourceLogger, - cancellationToken).ConfigureAwait(false); - - return (args, failedToApplyArgs); - } - private async Task> BuildCreateFilesAsync(IResource modelResource, CancellationToken cancellationToken) { var createFiles = new List(); @@ -2254,32 +2377,6 @@ private async Task> BuildCreateFilesAsync(IResou return createFiles; } - private async Task<(List, bool)> BuildEnvVarsAsync(ILogger resourceLogger, IResource modelResource, CancellationToken cancellationToken) - { - var failedToApplyConfiguration = false; - var env = new List(); - - await modelResource.ProcessEnvironmentVariableValuesAsync( - _executionContext, - (key, unprocessed, value, ex) => - { - if (ex is not null) - { - failedToApplyConfiguration = true; - resourceLogger.LogCritical(ex, "Failed to apply environment variable '{Name}'. A dependency may have failed to start.", key); - _logger.LogDebug(ex, "Failed to apply environment variable '{Name}' to '{ResourceName}'. A dependency may have failed to start.", key, modelResource.Name); - } - else if (value is string s) - { - env.Add(new EnvVar { Name = key, Value = s }); - } - }, - resourceLogger, - cancellationToken).ConfigureAwait(false); - - return (env, failedToApplyConfiguration); - } - private async Task<(List, bool)> BuildRunArgsAsync(ILogger resourceLogger, IResource modelResource, CancellationToken cancellationToken) { var failedToApplyArgs = false; @@ -2306,522 +2403,6 @@ await modelResource.ProcessContainerRuntimeArgValues( return (runArgs, failedToApplyArgs); } - /// - /// Build up the certificate authority trust configuration for an executable. - /// - /// The logger for the resource. - /// The executable IResource. - /// A that can be used to cancel the operation. - /// A representing the asynchronous operation. - private async Task<(List<(string, bool)>, List, bool)> BuildExecutableCertificateTrustConfigAsync( - ILogger resourceLogger, - IResource modelResource, - CancellationToken cancellationToken) - { - var certificatesRootDir = Path.Join(_locations.DcpSessionDir, modelResource.Name); - var bundleOutputPath = Path.Join(certificatesRootDir, "cert.pem"); - var certificatesOutputPath = Path.Join(certificatesRootDir, "certs"); - - bool failedToApplyConfig = false; - var args = new List<(string Value, bool IsSensitive)>(); - var env = new List(); - - (_, var certificates) = await modelResource.ProcessCertificateTrustConfigAsync( - _executionContext, - (unprocessed, value, ex, isSensitive) => - { - if (ex is not null) - { - failedToApplyConfig = true; - - resourceLogger.LogCritical(ex, "Failed to apply argument value '{ArgKey}'. A dependency may have failed to start.", ex.Data["ArgKey"]); - _logger.LogDebug(ex, "Failed to apply argument value '{ArgKey}' to '{ResourceName}'. A dependency may have failed to start.", ex.Data["ArgKey"], modelResource.Name); - } - else if (value is { } argument) - { - args.Add((argument, isSensitive)); - } - }, - (key, unprocessed, value, ex) => - { - if (ex is not null) - { - failedToApplyConfig = true; - - resourceLogger.LogCritical(ex, "Failed to apply environment variable '{Name}'. A dependency may have failed to start.", key); - _logger.LogDebug(ex, "Failed to apply environment variable '{Name}' to '{ResourceName}'. A dependency may have failed to start.", key, modelResource.Name); - } - else if (value is string s) - { - env.Add(new EnvVar { Name = key, Value = s }); - } - }, - resourceLogger, - (scope) => ReferenceExpression.Create($"{bundleOutputPath}"), - (scope) => ReferenceExpression.Create($"{certificatesOutputPath}"), - cancellationToken).ConfigureAwait(false); - - if (certificates?.Any() == true) - { - Directory.CreateDirectory(certificatesOutputPath); - - // First build a CA bundle (concatenation of all certs in PEM format) - var caBundleBuilder = new StringBuilder(); - foreach (var cert in certificates) - { - caBundleBuilder.Append(cert.ExportCertificatePem()); - caBundleBuilder.Append('\n'); - - // TODO: Add support in DCP to generate OpenSSL compatible symlinks for executable resources - File.WriteAllText(Path.Join(certificatesOutputPath, cert.Thumbprint + ".pem"), cert.ExportCertificatePem()); - } - - File.WriteAllText(bundleOutputPath, caBundleBuilder.ToString()); - } - - return (args, env, failedToApplyConfig); - } - - /// - /// Build up the certificate authority trust configuration for a container. - /// - /// The logger for the resource. - /// The container IResource. - /// A that can be used to cancel the operation. - /// A representing the asynchronous operation. - private async Task<(List<(string Value, bool isSensitive)>, List, List, bool)> BuildContainerCertificateAuthorityTrustAsync( - ILogger resourceLogger, - IResource modelResource, - CancellationToken cancellationToken) - { - var certificatesDestination = ContainerCertificatePathsAnnotation.DefaultCustomCertificatesDestination; - var bundlePaths = ContainerCertificatePathsAnnotation.DefaultCertificateBundlePaths.ToList(); - var certificateDirsPaths = ContainerCertificatePathsAnnotation.DefaultCertificateDirectoriesPaths.ToList(); - - if (modelResource.TryGetLastAnnotation(out var pathsAnnotation)) - { - certificatesDestination = pathsAnnotation.CustomCertificatesDestination ?? certificatesDestination; - bundlePaths = pathsAnnotation.DefaultCertificateBundles ?? bundlePaths; - certificateDirsPaths = pathsAnnotation.DefaultCertificateDirectories ?? certificateDirsPaths; - } - - bool failedToApplyConfig = false; - var args = new List<(string Value, bool IsSensitive)>(); - var env = new List(); - var createFiles = new List(); - - (var scope, var certificates) = await modelResource.ProcessCertificateTrustConfigAsync( - _executionContext, - (unprocessed, value, ex, isSensitive) => - { - if (ex is not null) - { - failedToApplyConfig = true; - - resourceLogger.LogCritical(ex, "Failed to apply argument value '{ArgKey}'. A dependency may have failed to start.", ex.Data["ArgKey"]); - _logger.LogDebug(ex, "Failed to apply argument value '{ArgKey}' to '{ResourceName}'. A dependency may have failed to start.", ex.Data["ArgKey"], modelResource.Name); - } - else if (value is { } argument) - { - args.Add((argument, isSensitive)); - } - }, - (key, unprocessed, value, ex) => - { - if (ex is not null) - { - failedToApplyConfig = true; - - resourceLogger.LogCritical(ex, "Failed to apply environment variable '{Name}'. A dependency may have failed to start.", key); - _logger.LogDebug(ex, "Failed to apply environment variable '{Name}' to '{ResourceName}'. A dependency may have failed to start.", key, modelResource.Name); - } - else if (value is string s) - { - env.Add(new EnvVar { Name = key, Value = s }); - } - }, - resourceLogger, - (scope) => ReferenceExpression.Create($"{certificatesDestination}/cert.pem"), - (scope) => - { - var dirs = new List { certificatesDestination + "/certs" }; - if (scope == CertificateTrustScope.Append) - { - // When appending to the default trust store, include the default certificate directories - dirs.AddRange(certificateDirsPaths!); - } - - // Build Linux PATH style colon-separated list of directories - return ReferenceExpression.Create($"{string.Join(':', dirs)}"); - }, - cancellationToken).ConfigureAwait(false); - - if (certificates?.Any() == true) - { - // First build a CA bundle (concatenation of all certs in PEM format) - var caBundleBuilder = new StringBuilder(); - var certificateFiles = new List(); - foreach (var cert in certificates.OrderBy(c => c.Thumbprint)) - { - caBundleBuilder.Append(cert.ExportCertificatePem()); - caBundleBuilder.Append('\n'); - certificateFiles.Add(new ContainerFileSystemEntry - { - Name = cert.Thumbprint + ".pem", - Type = ContainerFileSystemEntryType.OpenSSL, - Contents = cert.ExportCertificatePem(), - ContinueOnError = true, - }); - } - - createFiles.Add(new() - { - Destination = certificatesDestination, - Entries = [ - new ContainerFileSystemEntry - { - Name = "cert.pem", - Contents = caBundleBuilder.ToString(), - }, - new ContainerFileSystemEntry - { - Name = "certs", - Type = ContainerFileSystemEntryType.Directory, - Entries = certificateFiles.ToList(), - } - ], - }); - - if (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. - // Group by common directory to avoid creating multiple file system entries for the same root directory. - foreach (var bundlePath in bundlePaths!.Select(bp => - { - var filename = Path.GetFileName(bp); - var dir = bp.Substring(0, bp.Length - filename.Length); - return (dir, filename); - }).GroupBy(parts => parts.dir)) - { - createFiles.Add(new ContainerCreateFileSystem - { - Destination = bundlePath.Key, - Entries = bundlePath.Select(bp => - new ContainerFileSystemEntry - { - Name = bp.filename, - Contents = caBundleBuilder.ToString(), - }).ToList(), - }); - } - } - } - - return (args, env, createFiles, failedToApplyConfig); - } - - private async Task<(List<(string, bool)>, List, bool)> BuildExecutableCertificateKeyPairAsync( - ILogger resourceLogger, - IResource modelResource, - CancellationToken cancellationToken = default) - { - var args = new List<(string, bool)>(); - var envVars = new List(); - - var failedToApplyConfiguration = false; - - try - { - var effectiveAnnotation = new CertificateKeyPairAnnotation(); - if (modelResource.TryGetLastAnnotation(out var annotation)) - { - effectiveAnnotation = annotation; - } - - if (effectiveAnnotation is null) - { - // Should never happen - return (args, envVars, false); - } - - X509Certificate2? certificate = effectiveAnnotation.Certificate; - if (certificate is null && effectiveAnnotation.UseDeveloperCertificate.GetValueOrDefault(_developerCertificateService.DefaultTlsTerminationEnabled)) - { - certificate = _developerCertificateService.Certificates.FirstOrDefault(); - } - - if (certificate is null) - { - // No certificate to configure, do nothing - return (args, envVars, false); - } - - var baseOutputPath = Path.Join(_locations.DcpSessionDir, modelResource.Name, "private"); - var keyOutputPath = Path.Join(baseOutputPath, $"{certificate.Thumbprint}.key"); - var certificateOutputPath = Path.Join(baseOutputPath, $"{certificate.Thumbprint}.pem"); - var pfxOutputPath = Path.Join(baseOutputPath, $"{certificate.Thumbprint}.pfx"); - - var context = new CertificateKeyPairConfigurationCallbackAnnotationContext - { - ExecutionContext = _executionContext, - Resource = modelResource, - Arguments = new(), - EnvironmentVariables = new(), - CertificatePath = ReferenceExpression.Create($"{certificateOutputPath}"), - KeyPath = ReferenceExpression.Create($"{keyOutputPath}"), - PfxPath = ReferenceExpression.Create($"{pfxOutputPath}"), - Password = effectiveAnnotation.Password, - CancellationToken = cancellationToken, - }; - - foreach (var callback in modelResource.TryGetAnnotationsOfType(out var callbacks) ? callbacks : Enumerable.Empty()) - { - await callback.Callback(context).ConfigureAwait(false); - } - - if (!context.Arguments.Any() && !context.EnvironmentVariables.Any()) - { - // No configuration requested, do nothing - resourceLogger.LogWarning("Resource '{ResourceName}' does not have certificate key pair configuration defined. No TLS key pair override will be applied.", modelResource.Name); - return (args, envVars, false); - } - - foreach (var a in context.Arguments) - { - try - { - var resolvedValue = await modelResource.ResolveValueAsync(_executionContext, resourceLogger, a, key: null, cancellationToken: cancellationToken).ConfigureAwait(false); - - if (resolvedValue?.Value != null) - { - args.Add((resolvedValue.Value, resolvedValue.IsSensitive)); - } - } - catch - { - failedToApplyConfiguration = true; - } - } - - foreach (var (key, expr) in context.EnvironmentVariables) - { - try - { - var resolvedValue = await modelResource.ResolveValueAsync(_executionContext, resourceLogger, expr, key, cancellationToken: cancellationToken).ConfigureAwait(false); - - if (resolvedValue?.Value is not null) - { - envVars.Add(new EnvVar - { - Name = key, - Value = resolvedValue.Value, - }); - } - } - catch - { - failedToApplyConfiguration = true; - } - } - - string? passphrase = null; - if (effectiveAnnotation.Password is { }) - { - passphrase = await effectiveAnnotation.Password.GetValueAsync(cancellationToken).ConfigureAwait(false); - } - - (var certificatePem, var keyPem) = GetCertificateKeyPair(certificate, passphrase); - var pfxBytes = certificate.Export(X509ContentType.Pfx, passphrase); - - var certificateBytes = Encoding.ASCII.GetBytes(certificatePem); - var keyBytes = Encoding.ASCII.GetBytes(keyPem); - - Directory.CreateDirectory(baseOutputPath); - - // Write each of the certificate, key, and PFX assets to the temp folder - File.WriteAllBytes(keyOutputPath, keyBytes); - File.WriteAllBytes(certificateOutputPath, certificateBytes); - File.WriteAllBytes(pfxOutputPath, pfxBytes); - - Array.Clear(keyPem, 0, keyPem.Length); - Array.Clear(keyBytes, 0, keyBytes.Length); - } - catch - { - failedToApplyConfiguration = true; - } - - return (args, envVars, failedToApplyConfiguration); - } - - private async Task<(List<(string, bool)>, List, List, bool)> BuildContainerCertificateKeyPairAsync( - ILogger resourceLogger, - IResource modelResource, - CancellationToken cancellationToken = default) - { - var args = new List<(string, bool)>(); - var envVars = new List(); - var createFiles = new List(); - - var failedToApplyConfiguration = false; - - try - { - var effectiveAnnotation = new CertificateKeyPairAnnotation(); - if (modelResource.TryGetLastAnnotation(out var annotation)) - { - effectiveAnnotation = annotation; - } - - if (effectiveAnnotation is null) - { - // Should never happen - return (args, envVars, createFiles, false); - } - - X509Certificate2? certificate = effectiveAnnotation.Certificate; - if (certificate is null && effectiveAnnotation.UseDeveloperCertificate.GetValueOrDefault(_developerCertificateService.DefaultTlsTerminationEnabled)) - { - certificate = _developerCertificateService.Certificates.FirstOrDefault(); - } - - if (certificate is null) - { - // No certificate to configure, do nothing - return (args, envVars, createFiles, false); - } - - var certificatesDestination = ContainerCertificatePathsAnnotation.DefaultCustomCertificatesDestination; - if (modelResource.TryGetLastAnnotation(out var pathsAnnotation)) - { - certificatesDestination = pathsAnnotation.CustomCertificatesDestination ?? certificatesDestination; - } - - var context = new CertificateKeyPairConfigurationCallbackAnnotationContext - { - ExecutionContext = _executionContext, - Resource = modelResource, - Arguments = new(), - EnvironmentVariables = new(), - CertificatePath = ReferenceExpression.Create($"{certificatesDestination}/private/{certificate.Thumbprint}.pem"), - KeyPath = ReferenceExpression.Create($"{certificatesDestination}/private/{certificate.Thumbprint}.key"), - PfxPath = ReferenceExpression.Create($"{certificatesDestination}/private/{certificate.Thumbprint}.pfx"), - Password = effectiveAnnotation.Password, - CancellationToken = cancellationToken, - }; - - foreach (var callback in modelResource.TryGetAnnotationsOfType(out var callbacks) ? callbacks : Enumerable.Empty()) - { - await callback.Callback(context).ConfigureAwait(false); - } - - if (!context.Arguments.Any() && !context.EnvironmentVariables.Any()) - { - // No configuration requested, do nothing - resourceLogger.LogWarning("Resource '{ResourceName}' does not have certificate key pair configuration defined. No TLS key pair override will be applied.", modelResource.Name); - return (args, envVars, createFiles, false); - } - - foreach (var a in context.Arguments) - { - try - { - var resolvedValue = await modelResource.ResolveValueAsync(_executionContext, resourceLogger, a, key: null, cancellationToken: cancellationToken).ConfigureAwait(false); - - if (resolvedValue?.Value != null) - { - args.Add((resolvedValue.Value, resolvedValue.IsSensitive)); - } - } - catch - { - failedToApplyConfiguration = true; - } - } - - foreach (var (key, expr) in context.EnvironmentVariables) - { - try - { - var resolvedValue = await modelResource.ResolveValueAsync(_executionContext, resourceLogger, expr, key, cancellationToken: cancellationToken).ConfigureAwait(false); - - if (resolvedValue?.Value is not null) - { - envVars.Add(new EnvVar - { - Name = key, - Value = resolvedValue.Value, - }); - } - } - catch - { - failedToApplyConfiguration = true; - } - } - - string? passphrase = null; - if (effectiveAnnotation.Password is { }) - { - passphrase = await effectiveAnnotation.Password.GetValueAsync(cancellationToken).ConfigureAwait(false); - } - - (var certificatePem, var keyPem) = GetCertificateKeyPair(certificate, passphrase); - var pfxBytes = certificate.Export(X509ContentType.Pfx, passphrase); - - // The PFX file is binary, so we need to write it to a temp file first - var baseOutputPath = Path.Join(_locations.DcpSessionDir, modelResource.Name, "private"); - var pfxOutputPath = Path.Join(baseOutputPath, $"{certificate.Thumbprint}.pfx"); - Directory.CreateDirectory(baseOutputPath); - File.WriteAllBytes(pfxOutputPath, pfxBytes); - - // Write the certificate and key to the container filesystem - createFiles.Add(new ContainerCreateFileSystem - { - Destination = certificatesDestination, - Entries = [ - new ContainerFileSystemEntry - { - Name = "private", - Type = ContainerFileSystemEntryType.Directory, - Entries = [ - new ContainerFileSystemEntry - { - Name = certificate.Thumbprint + ".key", - Type = ContainerFileSystemEntryType.File, - Contents = new string(keyPem), - }, - new ContainerFileSystemEntry - { - Name = certificate.Thumbprint + ".pem", - Type = ContainerFileSystemEntryType.File, - Contents = new string(certificatePem), - }, - // Copy the PFX file from the temp location - new ContainerFileSystemEntry - { - Name = certificate.Thumbprint + ".pfx", - Type = ContainerFileSystemEntryType.File, - Source = pfxOutputPath, - }, - ], - }, - ], - }); - - Array.Clear(keyPem, 0, keyPem.Length); - } - catch (Exception ex) - { - failedToApplyConfiguration = true; - resourceLogger.LogCritical(ex, "Failed to apply certificate key pair configuration. A dependency may have failed to start."); - _logger.LogDebug(ex, "Failed to apply certificate key pair configuration to '{ResourceName}'. A dependency may have failed to start.", modelResource.Name); - } - - return (args, envVars, createFiles, failedToApplyConfiguration); - } - private static (char[] certificatePem, char[] keyPem) GetCertificateKeyPair(X509Certificate2 certificate, string? passphrase) { // See: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/CertificateGeneration/CertificateManager.cs diff --git a/src/Aspire.Hosting/Dcp/Model/Container.cs b/src/Aspire.Hosting/Dcp/Model/Container.cs index 4ce76601c3d..8c985dcf139 100644 --- a/src/Aspire.Hosting/Dcp/Model/Container.cs +++ b/src/Aspire.Hosting/Dcp/Model/Container.cs @@ -91,6 +91,10 @@ internal sealed class ContainerSpec [JsonPropertyName("createFiles")] public List? CreateFiles { get; set; } + + // List of public PEM certificates to be trusted by the container + [JsonPropertyName("pemCertificates")] + public ContainerPemCertificates? PemCertificates { get; set; } } internal sealed class BuildContext @@ -439,6 +443,25 @@ internal static class ContainerFileSystemEntryType public const string OpenSSL = "openssl"; } +internal sealed class ContainerPemCertificates +{ + // The destination in the container the certificates should be written to + [JsonPropertyName("destination")] + public string? Destination { get; set; } + + // The list of PEM encoded certificates to write + [JsonPropertyName("certificates")] + public List? Certificates { get; set; } + + // Optional list of bundle paths to overwrite in the container with the generated CA bundle + [JsonPropertyName("overwriteBundlePaths")] + public List? OverwriteBundlePaths { get; set; } + + // Should resource creation continue if there are errors writing one or more certificates? + [JsonPropertyName("continueOnError")] + public bool ContinueOnError { get; set; } +} + internal sealed record ContainerStatus : V1Status { // Container name displayed in Docker diff --git a/src/Aspire.Hosting/Dcp/Model/Executable.cs b/src/Aspire.Hosting/Dcp/Model/Executable.cs index dab00c29d06..48a3ef07a01 100644 --- a/src/Aspire.Hosting/Dcp/Model/Executable.cs +++ b/src/Aspire.Hosting/Dcp/Model/Executable.cs @@ -63,6 +63,12 @@ internal sealed class ExecutableSpec /// [JsonPropertyName("ambientEnvironment")] public AmbientEnvironment? AmbientEnvironment { get; set; } + + /// + /// Public PEM certificates to be configured for the Executable. + /// + [JsonPropertyName("pemCertificates")] + public ExecutablePemCertificates? PemCertificates { get; set; } } internal sealed class AmbientEnvironment @@ -101,6 +107,18 @@ internal static class ExecutionType public const string IDE = "IDE"; } +internal sealed class ExecutablePemCertificates +{ + // The list of public PEM encoded certificates for the executable. + [JsonPropertyName("certificates")] + public List? Certificates { get; set; } + + // Indicates whether to continue starting the Executable if there are issues setting up any certificates for + // the executable. + [JsonPropertyName("continueOnError")] + public bool ContinueOnError { get; set; } +} + internal sealed record ExecutableStatus : V1Status { /// diff --git a/src/Aspire.Hosting/Dcp/Model/PemCertificate.cs b/src/Aspire.Hosting/Dcp/Model/PemCertificate.cs new file mode 100644 index 00000000000..94db1158a39 --- /dev/null +++ b/src/Aspire.Hosting/Dcp/Model/PemCertificate.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Aspire.Hosting.Dcp.Model; + +// Represents a public PEM encoded certificate +internal sealed class PemCertificate +{ + // Thumbprint of the certificate + [JsonPropertyName("thumbprint")] + public string? Thumbprint { get; set; } + + // The PEM encoded contents of the public certificate + [JsonPropertyName("contents")] + public string? Contents { get; set; } +} \ No newline at end of file diff --git a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs index 6d9e523ede6..12d7c2872b4 100644 --- a/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs +++ b/tests/Aspire.Hosting.Tests/DistributedApplicationTests.cs @@ -1,6 +1,8 @@ // 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.Globalization; using System.Text.RegularExpressions; using System.Threading.Channels; @@ -641,10 +643,8 @@ public async Task VerifyRedisWithCertificateKeyPair() X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); using var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); -#pragma warning disable ASPIRECERTIFICATES001 var redis = testProgram.AppBuilder.AddRedis($"{testName}-redis") .WithCertificateKeyPair(cert); -#pragma warning restore ASPIRECERTIFICATES001 await using var app = testProgram.Build(); @@ -676,66 +676,25 @@ public async Task VerifyRedisWithCertificateKeyPair() [Theory] [RequiresDocker] [RequiresDevCert] - [InlineData(null, null, true, false, CertificateTrustScope.Append)] - [InlineData(null, false, false, false, CertificateTrustScope.Append)] - [InlineData(null, true, true, false, CertificateTrustScope.Append)] - [InlineData(false, null, false, false, CertificateTrustScope.Append)] [InlineData(false, false, false, false, CertificateTrustScope.Append)] [InlineData(false, true, true, false, CertificateTrustScope.Append)] - [InlineData(true, null, true, false, CertificateTrustScope.Append)] [InlineData(true, false, false, false, CertificateTrustScope.Append)] - [InlineData(true, true, true, false, CertificateTrustScope.Append)] - [InlineData(null, null, true, true, CertificateTrustScope.Append)] - [InlineData(null, false, false, true, CertificateTrustScope.Append)] - [InlineData(null, true, true, true, CertificateTrustScope.Append)] - [InlineData(false, null, false, true, CertificateTrustScope.Append)] - [InlineData(false, false, false, true, CertificateTrustScope.Append)] - [InlineData(false, true, true, true, CertificateTrustScope.Append)] - [InlineData(true, null, true, true, CertificateTrustScope.Append)] - [InlineData(true, false, false, true, CertificateTrustScope.Append)] - [InlineData(true, true, true, true, CertificateTrustScope.Append)] - [InlineData(null, null, true, false, CertificateTrustScope.Override)] - [InlineData(null, false, false, false, CertificateTrustScope.Override)] - [InlineData(null, true, true, false, CertificateTrustScope.Override)] - [InlineData(false, null, false, false, CertificateTrustScope.Override)] [InlineData(false, false, false, false, CertificateTrustScope.Override)] [InlineData(false, true, true, false, CertificateTrustScope.Override)] - [InlineData(true, null, true, false, CertificateTrustScope.Override)] [InlineData(true, false, false, false, CertificateTrustScope.Override)] - [InlineData(true, true, true, false, CertificateTrustScope.Override)] - [InlineData(null, null, true, true, CertificateTrustScope.Override)] - [InlineData(null, false, false, true, CertificateTrustScope.Override)] - [InlineData(null, true, true, true, CertificateTrustScope.Override)] - [InlineData(false, null, false, true, CertificateTrustScope.Override)] [InlineData(false, false, false, true, CertificateTrustScope.Override)] [InlineData(false, true, true, true, CertificateTrustScope.Override)] - [InlineData(true, null, true, true, CertificateTrustScope.Override)] [InlineData(true, false, false, true, CertificateTrustScope.Override)] - [InlineData(true, true, true, true, CertificateTrustScope.Override)] - [InlineData(null, null, false, false, CertificateTrustScope.None)] - [InlineData(null, false, false, false, CertificateTrustScope.None)] - [InlineData(null, true, false, false, CertificateTrustScope.None)] - [InlineData(false, null, false, false, CertificateTrustScope.None)] [InlineData(false, false, false, false, CertificateTrustScope.None)] [InlineData(false, true, false, false, CertificateTrustScope.None)] - [InlineData(true, null, false, false, CertificateTrustScope.None)] [InlineData(true, false, false, false, CertificateTrustScope.None)] - [InlineData(true, true, false, false, CertificateTrustScope.None)] - [InlineData(null, null, false, true, CertificateTrustScope.None)] - [InlineData(null, false, false, true, CertificateTrustScope.None)] - [InlineData(null, true, false, true, CertificateTrustScope.None)] - [InlineData(false, null, false, true, CertificateTrustScope.None)] - [InlineData(false, false, false, true, CertificateTrustScope.None)] - [InlineData(false, true, false, true, CertificateTrustScope.None)] - [InlineData(true, null, false, true, CertificateTrustScope.None)] - [InlineData(true, false, false, true, CertificateTrustScope.None)] - [InlineData(true, true, false, true, CertificateTrustScope.None)] public async Task VerifyContainerIncludesExpectedDevCertificateConfiguration(bool? implicitTrust, bool? explicitTrust, bool expectDevCert, bool overridePaths, CertificateTrustScope trustScope) { using var testProgram = CreateTestProgram("verify-container-dev-cert", trustDeveloperCertificate: implicitTrust); SetupXUnitLogging(testProgram.AppBuilder.Services); - var container = AddRedisContainer(testProgram.AppBuilder, "verify-container-dev-cert-redis"); + var container = AddRedisContainer(testProgram.AppBuilder, "verify-container-dev-cert-redis") + .WithoutCertificateKeyPair(); if (explicitTrust.HasValue) { container.WithDeveloperCertificateTrust(explicitTrust.Value); @@ -767,9 +726,7 @@ public async Task VerifyContainerIncludesExpectedDevCertificateConfiguration(boo await app.StartAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); var s = app.Services.GetRequiredService(); -#pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var dc = app.Services.GetRequiredService(); -#pragma warning restore ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. var list = await s.ListAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); Assert.Collection(list, @@ -810,71 +767,33 @@ public async Task VerifyContainerIncludesExpectedDevCertificateConfiguration(boo } }); - Assert.NotNull(item.Spec.CreateFiles); - Assert.Collection(item.Spec.CreateFiles.Where(cf => cf.Destination == expectedDestination), - createCerts => + if (trustScope == CertificateTrustScope.None) + { + Assert.Empty(item.Spec?.PemCertificates?.Certificates ?? []); + return; + } + + foreach (var cert in dc.Certificates) + { + var foundCert = Assert.Single(item.Spec?.PemCertificates?.Certificates ?? [], c => string.Equals(c.Thumbprint, cert.Thumbprint, StringComparison.Ordinal)); + if (cert.IsAspNetCoreDevelopmentCertificate()) { - Assert.NotNull(createCerts.Entries); - Assert.Collection(createCerts.Entries, - bundle => - { - Assert.Equal("cert.pem", bundle.Name); - Assert.Equal(ContainerFileSystemEntryType.File, bundle.Type); - var certs = new X509Certificate2Collection(); - certs.ImportFromPem(bundle.Contents); - Assert.Equal(dc.Certificates.Count, certs.Count); - Assert.All(certs, (cert) => cert.IsAspNetCoreDevelopmentCertificate()); - }, - dir => - { - Assert.Equal("certs", dir.Name); - Assert.Equal(ContainerFileSystemEntryType.Directory, dir.Type); - Assert.NotNull(dir.Entries); - Assert.Equal(dc.Certificates.Count, dir.Entries.Count); - foreach (var devCert in dc.Certificates) - { - Assert.Contains(dir.Entries, (cert) => - { - return cert.Type == ContainerFileSystemEntryType.OpenSSL && string.Equals(cert.Name, devCert.Thumbprint + ".pem", StringComparison.Ordinal) && string.Equals(cert.Contents, devCert.ExportCertificatePem(), StringComparison.Ordinal); - }); - } - }); - }); + Assert.True(X509Certificate2.CreateFromPem(foundCert.Contents).IsAspNetCoreDevelopmentCertificate()); + } + } if (trustScope == CertificateTrustScope.Override) { - foreach (var bundlePath in expectedDefaultBundleFiles!.Select(bp => - { - var filename = Path.GetFileName(bp); - var dir = bp.Substring(0, bp.Length - filename.Length); - return (dir, filename); - }).GroupBy(parts => parts.dir)) + Assert.Equal(expectedDefaultBundleFiles.Count, item.Spec?.PemCertificates?.OverwriteBundlePaths?.Count ?? 0); + foreach (var bundlePath in expectedDefaultBundleFiles) { - Assert.Collection(item.Spec.CreateFiles.Where(cf => cf.Destination == bundlePath.Key), - createCerts => - { - Assert.NotNull(createCerts.Entries); - Assert.Equal(bundlePath.Count(), createCerts.Entries.Count); - foreach (var expectedFile in bundlePath) - { - Assert.Collection(createCerts.Entries.Where(file => file.Name == expectedFile.filename), - bundle => - { - Assert.Equal(expectedFile.filename, bundle.Name); - Assert.Equal(ContainerFileSystemEntryType.File, bundle.Type); - var certs = new X509Certificate2Collection(); - certs.ImportFromPem(bundle.Contents); - Assert.Equal(dc.Certificates.Count, certs.Count); - Assert.All(certs, (cert) => cert.IsAspNetCoreDevelopmentCertificate()); - }); - } - }); + Assert.Contains(bundlePath, item.Spec?.PemCertificates?.OverwriteBundlePaths ?? []); } } } else { - Assert.Empty(item.Spec.CreateFiles ?? []); + Assert.Empty(item.Spec?.PemCertificates?.Certificates ?? []); } }); @@ -911,6 +830,93 @@ public async Task VerifyContainerSucceedsWithCreateFileContinueOnError() await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); } + [Fact] + [RequiresDocker] + [RequiresDevCert] + public async Task VerifyEnvironmentVariablesAvailableInCertificateTrustConfigCallback() + { + using var testProgram = CreateTestProgram("verify-env-vars-in-cert-callback", trustDeveloperCertificate: true); + SetupXUnitLogging(testProgram.AppBuilder.Services); + + var value = "SomeValue"; + var container = AddRedisContainer(testProgram.AppBuilder, "verify-env-vars-in-cert-callback-redis") + .WithEnvironment("INITIAL_ENV_VAR", "InitialValue") + .WithEnvironment("INITIAL_REFERENCE_EXPRESSION", ReferenceExpression.Create($"{value}")) + .WithCertificateTrustConfiguration(ctx => + { + if (ctx.EnvironmentVariables.ContainsKey("INITIAL_ENV_VAR")) + { + // Add an additional environment variable in the callback + ctx.EnvironmentVariables["CALLBACK_ADDED_VAR"] = "CallbackValue"; + var initialRE = Assert.IsType(ctx.EnvironmentVariables["INITIAL_REFERENCE_EXPRESSION"]); + ctx.EnvironmentVariables["INITIAL_REFERENCE_EXPRESSION"] = ReferenceExpression.Create($"{initialRE}_AppendedInCallback"); + } + + return Task.CompletedTask; + }); + + await using var app = testProgram.Build(); + + await app.StartAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + + var s = app.Services.GetRequiredService(); + var suffix = app.Services.GetRequiredService>().Value.ResourceNameSuffix; + var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync( + s, + $"verify-env-vars-in-cert-callback-redis-{ReplicaIdRegex}-{suffix}", + r => r.Spec.Env != null).DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + + Assert.NotNull(redisContainer); + Assert.NotNull(redisContainer.Spec.Env); + + // Verify both environment variables are present in the final container spec + Assert.Single(redisContainer.Spec.Env, e => e.Name == "INITIAL_ENV_VAR" && e.Value == "InitialValue"); + Assert.Single(redisContainer.Spec.Env, e => e.Name == "CALLBACK_ADDED_VAR" && e.Value == "CallbackValue"); + Assert.Single(redisContainer.Spec.Env, e => e.Name == "INITIAL_REFERENCE_EXPRESSION" && e.Value == $"{value}_AppendedInCallback"); + + await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + } + + [Fact] + [RequiresDocker] + public async Task VerifyEnvironmentVariablesAppliedWithoutCertificateTrustConfig() + { + // Don't apply developer certificate trust so the config callback shouldn't be invoked + using var testProgram = CreateTestProgram("verify-env-vars-in-cert-callback", trustDeveloperCertificate: false); + SetupXUnitLogging(testProgram.AppBuilder.Services); + + var value = "SomeValue"; + var container = AddRedisContainer(testProgram.AppBuilder, "verify-env-vars-in-cert-callback-redis") + .WithEnvironment("INITIAL_ENV_VAR", "InitialValue") + .WithEnvironment("INITIAL_REFERENCE_EXPRESSION", ReferenceExpression.Create($"{value}")) + .WithCertificateTrustConfiguration(ctx => + { + Assert.Fail("Certificate trust configuration callback should not be invoked when developer certificate trust is not applied."); + + return Task.CompletedTask; + }); + + await using var app = testProgram.Build(); + + await app.StartAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + + var s = app.Services.GetRequiredService(); + var suffix = app.Services.GetRequiredService>().Value.ResourceNameSuffix; + var redisContainer = await KubernetesHelper.GetResourceByNameMatchAsync( + s, + $"verify-env-vars-in-cert-callback-redis-{ReplicaIdRegex}-{suffix}", + r => r.Spec.Env != null).DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + + Assert.NotNull(redisContainer); + Assert.NotNull(redisContainer.Spec.Env); + + // Verify both environment variables are present in the final container spec + Assert.Single(redisContainer.Spec.Env, e => e.Name == "INITIAL_ENV_VAR" && e.Value == "InitialValue"); + Assert.Single(redisContainer.Spec.Env, e => e.Name == "INITIAL_REFERENCE_EXPRESSION" && e.Value == $"{value}"); + + await app.StopAsync().DefaultTimeout(TestConstants.DefaultOrchestratorTestLongTimeout); + } + [Fact] [RequiresDocker] public async Task VerifyContainerStopStartWorks() @@ -1758,7 +1764,7 @@ private static TestProgram CreateTestProgram( bool includeIntegrationServices = false, bool disableDashboard = true, bool randomizePorts = true, - bool? trustDeveloperCertificate = null) => + bool? trustDeveloperCertificate = false) => TestProgram.Create( testName, args, diff --git a/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs b/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs index 9ecb9366ab3..80359b7b31c 100644 --- a/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs +++ b/tests/Aspire.Hosting.Tests/Utils/TestDistributedApplicationBuilder.cs @@ -33,12 +33,12 @@ public static IDistributedApplicationTestingBuilder Create(DistributedApplicatio public static IDistributedApplicationTestingBuilder Create(params string[] args) { - return CreateCore(args, (_) => { }); + return CreateCore(args, (options) => { options.TrustDeveloperCertificate = false; }); } public static IDistributedApplicationTestingBuilder Create(ITestOutputHelper testOutputHelper, params string[] args) { - return CreateCore(args, (_) => { }, testOutputHelper); + return CreateCore(args, (options) => { options.TrustDeveloperCertificate = false; }, testOutputHelper); } public static IDistributedApplicationTestingBuilder Create(Action? configureOptions, ITestOutputHelper? testOutputHelper = null) diff --git a/tests/Shared/TemplatesTesting/BuildEnvironment.cs b/tests/Shared/TemplatesTesting/BuildEnvironment.cs index bc003415e01..7cfb42947b9 100644 --- a/tests/Shared/TemplatesTesting/BuildEnvironment.cs +++ b/tests/Shared/TemplatesTesting/BuildEnvironment.cs @@ -184,6 +184,12 @@ public BuildEnvironment(bool useSystemDotNet = false, string sdkDirName = "dotne EnvVars["_MSBUILDTLENABLED"] = "0"; EnvVars["SkipAspireWorkloadManifest"] = "true"; + if (OperatingSystem.IsMacOS()) + { + // Disable default developer certificate features in MacOS due to test performance issues + EnvVars["ASPIRE_DEVELOPER_CERTIFICATE_DEFAULT_TRUST"] = "false"; + } + DotNet = Path.Combine(sdkForTemplatePath!, "dotnet"); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {