diff --git a/src/Aspire.Hosting.Dapr/CommandLineBuilder.cs b/src/Aspire.Hosting.Dapr/CommandLineBuilder.cs index 92ebd8c3f8..35cd86659f 100644 --- a/src/Aspire.Hosting.Dapr/CommandLineBuilder.cs +++ b/src/Aspire.Hosting.Dapr/CommandLineBuilder.cs @@ -6,9 +6,9 @@ namespace Aspire.Hosting.Dapr; -internal delegate IEnumerable CommandLineArgBuilder(); +internal delegate IEnumerable CommandLineArgBuilder(); -internal sealed record CommandLine(string FileName, IEnumerable Arguments) +internal sealed record CommandLine(string FileName, IEnumerable Arguments) { public string ArgumentString { @@ -178,6 +178,26 @@ private static CommandLineArgBuilder ModelNamedStringArg(string name, string val }; } + public static CommandLineArgBuilder ModelNamedObjectArg(string name, object value) + { + return () => + { + return [name, value]; + }; + } + + public static CommandLineArgBuilder ModelNamedObjectArg(string name, object value, bool assignValue = false) where T : struct + { + return () => + { + string? stringValue = Convert.ToString(value, CultureInfo.InvariantCulture); + + return stringValue is not null + ? ModelNamedStringArg(name, stringValue, assignValue)() + : Enumerable.Empty(); + }; + } + public static CommandLineArgBuilder PostOptionsArgs(params CommandLineArgBuilder[] args) { return PostOptionsArgs(null, args); @@ -190,7 +210,7 @@ public static CommandLineArgBuilder PostOptionsArgs(string? separator, params Co public static CommandLineArgBuilder PostOptionsArgs(string? separator, IEnumerable args) { - IEnumerable GeneratePostOptionsArgs() + IEnumerable GeneratePostOptionsArgs() { bool postOptions = false; diff --git a/src/Aspire.Hosting.Dapr/DaprDistributedApplicationLifecycleHook.cs b/src/Aspire.Hosting.Dapr/DaprDistributedApplicationLifecycleHook.cs index 373fde2962..311994eabe 100644 --- a/src/Aspire.Hosting.Dapr/DaprDistributedApplicationLifecycleHook.cs +++ b/src/Aspire.Hosting.Dapr/DaprDistributedApplicationLifecycleHook.cs @@ -94,10 +94,10 @@ public async Task BeforeStartAsync(DistributedApplicationModel appModel, Cancell } var daprAppPortArg = (int? port) => ModelNamedArg("--app-port", port); - var daprGrpcPortArg = (string? port) => ModelNamedArg("--dapr-grpc-port", port); - var daprHttpPortArg = (string? port) => ModelNamedArg("--dapr-http-port", port); - var daprMetricsPortArg = (string? port) => ModelNamedArg("--metrics-port", port); - var daprProfilePortArg = (string? port) => ModelNamedArg("--profile-port", port); + var daprGrpcPortArg = (object port) => ModelNamedObjectArg("--dapr-grpc-port", port); + var daprHttpPortArg = (object port) => ModelNamedObjectArg("--dapr-http-port", port); + var daprMetricsPortArg = (object port) => ModelNamedObjectArg("--metrics-port", port); + var daprProfilePortArg = (object port) => ModelNamedObjectArg("--profile-port", port); var daprAppChannelAddressArg = (string? address) => ModelNamedArg("--app-channel-address", address); var appId = sidecarOptions?.AppId ?? resource.Name; @@ -182,13 +182,21 @@ public async Task BeforeStartAsync(DistributedApplicationModel appModel, Cancell } } - updatedArgs.AddRange(daprGrpcPortArg($"{{{{- portForServing \"{daprCliResourceName}_grpc\" -}}}}")()); - updatedArgs.AddRange(daprHttpPortArg($"{{{{- portForServing \"{daprCliResourceName}_http\" -}}}}")()); - updatedArgs.AddRange(daprMetricsPortArg($"{{{{- portForServing \"{daprCliResourceName}_metrics\" -}}}}")()); + var grpc = daprCli.GetEndpoint("grpc"); + var http = daprCli.GetEndpoint("http"); + var metrics = daprCli.GetEndpoint("metrics"); + + updatedArgs.AddRange(daprGrpcPortArg(grpc.Property(EndpointProperty.TargetPort))()); + updatedArgs.AddRange(daprHttpPortArg(http.Property(EndpointProperty.TargetPort))()); + updatedArgs.AddRange(daprMetricsPortArg(metrics.Property(EndpointProperty.TargetPort))()); + if (sidecarOptions?.EnableProfiling == true) { - updatedArgs.AddRange(daprProfilePortArg($"{{{{- portForServing \"{daprCliResourceName}_profile\" -}}}}")()); + var profiling = daprCli.GetEndpoint("profiling"); + + updatedArgs.AddRange(daprProfilePortArg(profiling.Property(EndpointProperty.TargetPort))()); } + if (sidecarOptions?.AppChannelAddress is null && httpEndPoint is not null) { updatedArgs.AddRange(daprAppChannelAddressArg(httpEndPoint.Host)()); diff --git a/src/Aspire.Hosting/ApplicationModel/AllocatedEndpoint.cs b/src/Aspire.Hosting/ApplicationModel/AllocatedEndpoint.cs index 3cd197db90..0ca34c5555 100644 --- a/src/Aspire.Hosting/ApplicationModel/AllocatedEndpoint.cs +++ b/src/Aspire.Hosting/ApplicationModel/AllocatedEndpoint.cs @@ -18,7 +18,8 @@ public class AllocatedEndpoint /// The IP address of the endpoint. /// The address of the container host. /// The port number of the endpoint. - public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, string? containerHostAddress = null) + /// The name of the DCP service. + public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, string? containerHostAddress = null, string? dcpServiceName = null) { ArgumentNullException.ThrowIfNull(endpoint); ArgumentOutOfRangeException.ThrowIfLessThan(port, 1, nameof(port)); @@ -28,6 +29,7 @@ public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, Address = address; ContainerHostAddress = containerHostAddress; Port = port; + DcpServiceName = dcpServiceName; } /// @@ -65,6 +67,11 @@ public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, /// public string UriString => $"{UriScheme}://{EndPointString}"; + /// + /// The associated service name created in DCP for this endpoint. + /// + public string? DcpServiceName { get; } + /// /// Returns a string representation of the allocated endpoint URI. /// diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs index e7a4a39ad6..d15b8e552a 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointAnnotation.cs @@ -26,9 +26,8 @@ public sealed class EndpointAnnotation : IResourceAnnotation /// Desired port for the service. /// This is the port the resource is listening on. If the endpoint is used for the container, it is the container port. /// Indicates that this endpoint should be exposed externally at publish time. - /// The name of the environment variable that will be set to the port number of this endpoint. /// Specifies if the endpoint will be proxied by DCP. Defaults to true. - public EndpointAnnotation(ProtocolType protocol, string? uriScheme = null, string? transport = null, string? name = null, int? port = null, int? targetPort = null, bool? isExternal = null, string? env = null, bool isProxied = true) + public EndpointAnnotation(ProtocolType protocol, string? uriScheme = null, string? transport = null, string? name = null, int? port = null, int? targetPort = null, bool? isExternal = null, bool isProxied = true) { // If the URI scheme is null, we'll adopt either udp:// or tcp:// based on the // protocol. If the name is null, we'll use the URI scheme as the default. This @@ -47,7 +46,6 @@ public EndpointAnnotation(ProtocolType protocol, string? uriScheme = null, strin Port = port; TargetPort = targetPort ?? port; IsExternal = isExternal ?? false; - EnvironmentVariable = env; IsProxied = isProxied; } @@ -93,11 +91,6 @@ public string Transport /// public bool IsExternal { get; set; } - /// - /// The name of the environment variable that will be set to the port number of this endpoint. - /// - public string? EnvironmentVariable { get; set; } - /// /// Indicates that this endpoint should be managed by DCP. This means it can be replicated and use a different port internally than the one publicly exposed. /// Setting to false means the endpoint will be handled and exposed by the resource. diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs index 7b61f25ea3..e632b6044c 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointReference.cs @@ -34,6 +34,11 @@ public sealed class EndpointReference : IManifestExpressionProvider, IValueProvi /// public bool IsAllocated => _isAllocated ??= GetAllocatedEndpoint() is not null; + /// + /// Gets a value indicating whether the endpoint exists. + /// + public bool Exists => GetEndpointAnnotation() is not null; + string IManifestExpressionProvider.ValueExpression => GetExpression(); ValueTask IValueProvider.GetValueAsync(CancellationToken cancellationToken) => new(Url); @@ -49,6 +54,7 @@ internal string GetExpression(EndpointProperty property = EndpointProperty.Url) EndpointProperty.Host or EndpointProperty.IPV4Host => "host", EndpointProperty.Port => "port", EndpointProperty.Scheme => "scheme", + EndpointProperty.TargetPort => "targetPort", _ => throw new InvalidOperationException($"The property '{property}' is not supported for the endpoint '{EndpointName}'.") }; @@ -72,6 +78,11 @@ public EndpointReferenceExpression Property(EndpointProperty property) /// public int Port => AllocatedEndpoint.Port; + /// + /// Gets the target port for this endpoint. If the port is dynamically allocated, this will return . + /// + public int? TargetPort => EndpointAnnotation.TargetPort; + /// /// Gets the host for this endpoint. /// @@ -85,14 +96,14 @@ public EndpointReferenceExpression Property(EndpointProperty property) /// /// Gets the scheme for this endpoint. /// - public string Scheme => AllocatedEndpoint.UriScheme; + public string Scheme => EndpointAnnotation.UriScheme; /// /// Gets the URL for this endpoint. /// public string Url => AllocatedEndpoint.UriString; - private AllocatedEndpoint AllocatedEndpoint => + internal AllocatedEndpoint AllocatedEndpoint => GetAllocatedEndpoint() ?? throw new InvalidOperationException($"The endpoint `{EndpointName}` is not allocated for the resource `{Resource.Name}`."); @@ -158,7 +169,7 @@ public class EndpointReferenceExpression(EndpointReference endpointReference, En /// Gets the value of the property of the endpoint. /// /// A . - /// A containing the selected value. + /// A containing the selected value. /// Throws when the selected enumeration is not known. public ValueTask GetValueAsync(CancellationToken cancellationToken) => Property switch { @@ -167,9 +178,27 @@ public class EndpointReferenceExpression(EndpointReference endpointReference, En EndpointProperty.IPV4Host => new("127.0.0.1"), EndpointProperty.Port => new(Endpoint.Port.ToString(CultureInfo.InvariantCulture)), EndpointProperty.Scheme => new(Endpoint.Scheme), + EndpointProperty.TargetPort => new(ComputeTargetPort()), _ => throw new InvalidOperationException($"The property '{Property}' is not supported for the endpoint '{Endpoint.EndpointName}'.") }; + private string? ComputeTargetPort() + { + // We have a target port, so we can return it directly. + if (Endpoint.TargetPort is int port) + { + return port.ToString(CultureInfo.InvariantCulture); + } + + // There is no way to resolve the value of the target port until runtime. Even then, replicas make this very complex because + // the target port is not known until the replica is allocated. + // Instead, we return an expression that will be resolved at runtime by the orchestrator. + return $$$"""{{- portForServing "{{{DcpServiceName}}}" -}}"""; + } + + private string DcpServiceName => Endpoint.AllocatedEndpoint.DcpServiceName + ?? throw new InvalidOperationException("The endpoint does not have an associated service name in the orchestrator."); + IEnumerable IValueWithReferences.References => [Endpoint]; } @@ -197,5 +226,9 @@ public enum EndpointProperty /// /// The scheme of the endpoint. /// - Scheme + Scheme, + /// + /// The target port of the endpoint. + /// + TargetPort } diff --git a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs index a0d096d25f..39007be98b 100644 --- a/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs +++ b/src/Aspire.Hosting/ApplicationModel/ReferenceExpression.cs @@ -83,26 +83,79 @@ public static ReferenceExpression Create(in ExpressionInterpolatedStringHandler { return handler.GetExpression(); } + + /// + /// Represents a handler for interpolated strings that contain expressions. Those expressions will either be literal strings or + /// instances of types that implement both and . + /// + /// The length of the literal part of the interpolated string. + /// The number of formatted parts in the interpolated string. + [InterpolatedStringHandler] + public ref struct ExpressionInterpolatedStringHandler(int literalLength, int formattedCount) + { + private readonly StringBuilder _builder = new(literalLength * 2); + private readonly List _valueProviders = new(formattedCount); + private readonly List _manifestExpressions = new(formattedCount); + + /// + /// Appends a literal value to the expression. + /// + /// The literal string value to be appended to the interpolated string. + public readonly void AppendLiteral(string value) + { + _builder.Append(value); + } + + /// + /// Appends a formatted value to the expression. + /// + /// The formatted string to be appended to the interpolated string. + public readonly void AppendFormatted(string? value) + { + _builder.Append(value); + } + + /// + /// Appends a formatted value to the expression. The value must implement and . + /// + /// An instance of an object which implements and . + /// + public void AppendFormatted(T valueProvider) where T : IValueProvider, IManifestExpressionProvider + { + var index = _valueProviders.Count; + _builder.Append(CultureInfo.InvariantCulture, $"{{{index}}}"); + + _valueProviders.Add(valueProvider); + _manifestExpressions.Add(valueProvider.ValueExpression); + } + + internal readonly ReferenceExpression GetExpression() => + new(_builder.ToString(), [.. _valueProviders], [.. _manifestExpressions]); + } } /// -/// Represents a handler for interpolated strings that contain expressions. Those expressions will either be literal strings or -/// instances of types that implement both and . +/// A builder for creating instances. /// -/// The length of the literal part of the interpolated string. -/// The number of formatted parts in the interpolated string. -[InterpolatedStringHandler] -public ref struct ExpressionInterpolatedStringHandler(int literalLength, int formattedCount) +public class ReferenceExpressionBuilder { - private readonly StringBuilder _builder = new(literalLength * 2); - private readonly List _valueProviders = new(formattedCount); - private readonly List _manifestExpressions = new(formattedCount); + private readonly StringBuilder _builder = new(); + private readonly List _valueProviders = new(); + private readonly List _manifestExpressions = new(); + + /// + /// Appends an interpolated string to the expression. + /// + /// + public void Append([InterpolatedStringHandlerArgument("")] in ReferenceExpressionBuilderInterpolatedStringHandler handler) + { + } /// /// Appends a literal value to the expression. /// /// The literal string value to be appended to the interpolated string. - public readonly void AppendLiteral(string value) + public void AppendLiteral(string value) { _builder.Append(value); } @@ -111,7 +164,7 @@ public readonly void AppendLiteral(string value) /// Appends a formatted value to the expression. /// /// The formatted string to be appended to the interpolated string. - public readonly void AppendFormatted(string? value) + public void AppendFormatted(string? value) { _builder.Append(value); } @@ -130,6 +183,50 @@ public void AppendFormatted(T valueProvider) where T : IValueProvider, IManif _manifestExpressions.Add(valueProvider.ValueExpression); } - internal readonly ReferenceExpression GetExpression() => + /// + /// Builds the . + /// + public ReferenceExpression Build() => ReferenceExpression.Create(_builder.ToString(), [.. _valueProviders], [.. _manifestExpressions]); + + /// + /// Represents a handler for interpolated strings that contain expressions. Those expressions will either be literal strings or + /// instances of types that implement both and . + /// + /// The length of the literal part of the interpolated string. + /// The number of formatted parts in the interpolated string. + /// The builder that will be used to create the . + [InterpolatedStringHandler] +#pragma warning disable CS9113 // Parameter is unread. + public ref struct ReferenceExpressionBuilderInterpolatedStringHandler(int literalLength, int formattedCount, ReferenceExpressionBuilder builder) +#pragma warning restore CS9113 // Parameter is unread. + { + /// + /// Appends a literal value to the expression. + /// + /// The literal string value to be appended to the interpolated string. + public readonly void AppendLiteral(string value) + { + builder.AppendLiteral(value); + } + + /// + /// Appends a formatted value to the expression. + /// + /// The formatted string to be appended to the interpolated string. + public readonly void AppendFormatted(string? value) + { + builder.AppendFormatted(value); + } + + /// + /// Appends a formatted value to the expression. The value must implement and . + /// + /// An instance of an object which implements and . + /// + public void AppendFormatted(T valueProvider) where T : IValueProvider, IManifestExpressionProvider + { + builder.AppendFormatted(valueProvider); + } + } } diff --git a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs index f9f6d395db..30f941ae34 100644 --- a/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs +++ b/src/Aspire.Hosting/Dcp/ApplicationExecutor.cs @@ -623,7 +623,7 @@ string CombineUrls(string url, string launchUrl) if (ep.EndpointAnnotation.IsProxied) { var endpointString = $"{ep.Scheme}://{endpoint.Spec.Address}:{endpoint.Spec.Port}"; - urls.Add(new(Name: $"{ep.EndpointName}-listen-port", Url: endpointString, IsInternal: true)); + urls.Add(new(Name: $"{ep.EndpointName} target port", Url: endpointString, IsInternal: true)); } } } @@ -891,7 +891,8 @@ private void AddAllocatedEndpointInfo(IEnumerable resources) sp.EndpointAnnotation, sp.EndpointAnnotation.IsProxied ? svc.AllocatedAddress! : "localhost", (int)svc.AllocatedPort!, - containerHostAddress: appResource.ModelResource.IsContainer() ? containerHost : null); + containerHostAddress: appResource.ModelResource.IsContainer() ? containerHost : null, + dcpServiceName: svc.Metadata.Name); } } } @@ -1034,18 +1035,14 @@ private void PrepareProjectExecutables() // and the environment variables/application URLs inside CreateExecutableAsync(). exeSpec.Args.Add("--no-launch-profile"); - var launchProfileName = project.SelectLaunchProfileName(); - if (!string.IsNullOrEmpty(launchProfileName)) + var launchProfile = project.GetEffectiveLaunchProfile(); + if (launchProfile is not null && !string.IsNullOrWhiteSpace(launchProfile.CommandLineArgs)) { - var launchProfile = project.GetEffectiveLaunchProfile(); - if (launchProfile is not null && !string.IsNullOrWhiteSpace(launchProfile.CommandLineArgs)) + var cmdArgs = launchProfile.CommandLineArgs.Split((string?)null, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (cmdArgs is not null && cmdArgs.Length > 0) { - var cmdArgs = launchProfile.CommandLineArgs.Split((string?)null, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - if (cmdArgs is not null && cmdArgs.Length > 0) - { - exeSpec.Args.Add("--"); - exeSpec.Args.AddRange(cmdArgs); - } + exeSpec.Args.Add("--"); + exeSpec.Args.AddRange(cmdArgs); } } } @@ -1185,32 +1182,6 @@ private async Task CreateExecutableAsync(AppResource er, ILogger resourceLogger, Logger = resourceLogger }; - // Need to apply configuration settings manually; see PrepareExecutables() for details. - if (er.ModelResource is ProjectResource project && project.SelectLaunchProfileName() is { } launchProfileName && project.GetLaunchSettings() is { } launchSettings) - { - ApplyLaunchProfile(er, config, launchProfileName, launchSettings); - } - else - { - if (er.ServicesProduced.Count > 0) - { - if (er.ModelResource is ProjectResource) - { - var urls = er.ServicesProduced.Where(IsUnspecifiedHttpService).Select(sar => - { - var url = sar.EndpointAnnotation.UriScheme + "://localhost:{{- portForServing \"" + sar.Service.Metadata.Name + "\" -}}"; - return url; - }); - - // REVIEW: Should we assume ASP.NET Core? - // We're going to use http and https urls as ASPNETCORE_URLS - config["ASPNETCORE_URLS"] = string.Join(";", urls); - } - - InjectPortEnvVars(er, config); - } - } - if (er.ModelResource.TryGetEnvironmentVariables(out var envVarAnnotations)) { foreach (var ann in envVarAnnotations) @@ -1314,72 +1285,6 @@ private async Task CreateExecutableAsync(AppResource er, ILogger resourceLogger, return value; } - private static void ApplyLaunchProfile(AppResource executableResource, Dictionary config, string launchProfileName, LaunchSettings launchSettings) - { - // Populate DOTNET_LAUNCH_PROFILE environment variable for consistency with "dotnet run" and "dotnet watch". - config.Add("DOTNET_LAUNCH_PROFILE", launchProfileName); - - var launchProfile = launchSettings.Profiles[launchProfileName]; - if (!string.IsNullOrWhiteSpace(launchProfile.ApplicationUrl)) - { - if (executableResource.DcpResource is ExecutableReplicaSet) - { - var urls = executableResource.ServicesProduced.Where(IsUnspecifiedHttpService).Select(sar => - { - var url = sar.EndpointAnnotation.UriScheme + "://localhost:{{- portForServing \"" + sar.Service.Metadata.Name + "\" -}}"; - return url; - }); - - config.Add("ASPNETCORE_URLS", string.Join(";", urls)); - } - else - { - config.Add("ASPNETCORE_URLS", launchProfile.ApplicationUrl); - } - - InjectPortEnvVars(executableResource, config); - } - - foreach (var envVar in launchProfile.EnvironmentVariables) - { - string value = Environment.ExpandEnvironmentVariables(envVar.Value); - config[envVar.Key] = value; - } - } - - private static void InjectPortEnvVars(AppResource executableResource, Dictionary config) - { - ServiceAppResource? httpsServiceAppResource = null; - // Inject environment variables for services produced by this executable. - foreach (var serviceProduced in executableResource.ServicesProduced) - { - var name = serviceProduced.Service.Metadata.Name; - var envVar = serviceProduced.EndpointAnnotation.EnvironmentVariable; - - if (envVar is not null) - { - config.Add(envVar, $"{{{{- portForServing \"{name}\" }}}}"); - } - - if (httpsServiceAppResource is null && serviceProduced.EndpointAnnotation.UriScheme == "https") - { - httpsServiceAppResource = serviceProduced; - } - } - - // REVIEW: If you run as an executable, we don't know that you're an ASP.NET Core application so we don't want to - // inject ASPNETCORE_HTTPS_PORT. - if (executableResource.ModelResource is ProjectResource) - { - // Add the environment variable for the HTTPS port if we have an HTTPS service. This will make sure the - // HTTPS redirection middleware avoids redirecting to the internal port. - if (httpsServiceAppResource is not null) - { - config.Add("ASPNETCORE_HTTPS_PORT", $"{{{{- portFor \"{httpsServiceAppResource.Service.Metadata.Name}\" }}}}"); - } - } - } - private void PrepareContainers() { var modelContainerResources = _model.GetContainerResources(); @@ -1535,14 +1440,6 @@ private async Task CreateContainerAsync(AppResource cr, ILogger resourceLogger, } dcpContainerResource.Spec.Ports.Add(portSpec); - - var name = sp.Service.Metadata.Name; - var envVar = sp.EndpointAnnotation.EnvironmentVariable; - - if (envVar is not null) - { - config.Add(envVar, $"{{{{- portForServing \"{name}\" }}}}"); - } } } @@ -1733,17 +1630,6 @@ private static string GenerateUniqueServiceName(HashSet serviceNames, st return uniqueName; } - // Returns true if this resource represents an HTTP service endpoint which does not specify an environment variable for the endpoint. - // This is used to decide whether the endpoint should be propagated via the ASPNETCORE_URLS environment variable. - private static bool IsUnspecifiedHttpService(ServiceAppResource serviceAppResource) - { - return serviceAppResource.EndpointAnnotation is - { - UriScheme: "http" or "https", - EnvironmentVariable: null or { Length: 0 } - }; - } - public async Task DeleteResourcesAsync(CancellationToken cancellationToken = default) { try diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index 605aac5924..54b599d29f 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -121,7 +121,7 @@ private static IResourceBuilder WithProjectDefaults(this IResou if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) { - // Automatically add EndpointAnnotation to project resources based on ApplicationUrl set in the launch profile. + // Process the launch profile and turn it into environment variables and endpoints. var launchProfile = projectResource.GetEffectiveLaunchProfile(throwIfNotFound: true); if (launchProfile is null) { @@ -141,6 +141,55 @@ private static IResourceBuilder WithProjectDefaults(this IResou }, createIfNotExists: true); } + + builder.WithEnvironment(context => + { + // Populate DOTNET_LAUNCH_PROFILE environment variable for consistency with "dotnet run" and "dotnet watch". + context.EnvironmentVariables.TryAdd("DOTNET_LAUNCH_PROFILE", launchProfileName!); + + foreach (var envVar in launchProfile.EnvironmentVariables) + { + var value = Environment.ExpandEnvironmentVariables(envVar.Value); + context.EnvironmentVariables.TryAdd(envVar.Key, value); + } + + if (context.EnvironmentVariables.ContainsKey("ASPNETCORE_URLS")) + { + // If the user has already set ASPNETCORE_URLS, we don't want to override it. + return; + } + + var aspnetCoreUrls = new ReferenceExpressionBuilder(); + + var processedHttpsPort = false; + var first = true; + + // Turn http and https endpoints into a single ASPNETCORE_URLS environment variable. + foreach (var e in builder.Resource.GetEndpoints().Where(e => e.EndpointAnnotation.UriScheme is "http" or "https")) + { + if (!first) + { + aspnetCoreUrls.AppendLiteral(";"); + } + + if (!processedHttpsPort && e.EndpointAnnotation.UriScheme == "https") + { + // Add the environment variable for the HTTPS port if we have an HTTPS service. This will make sure the + // HTTPS redirection middleware avoids redirecting to the internal port. + context.EnvironmentVariables["ASPNETCORE_HTTPS_PORT"] = e.Property(EndpointProperty.Port); + + processedHttpsPort = true; + } + + aspnetCoreUrls.Append($"{e.Property(EndpointProperty.Scheme)}://localhost:{e.Property(EndpointProperty.TargetPort)}"); + first = false; + } + + // Combine into a single expression + context.EnvironmentVariables["ASPNETCORE_URLS"] = aspnetCoreUrls.Build(); + }); + + // TODO: Process command line arguments here } else { diff --git a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs index feefc15821..5c52991348 100644 --- a/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs +++ b/src/Aspire.Hosting/Publishing/ManifestPublishingContext.cs @@ -366,8 +366,6 @@ public async Task WriteEnvironmentVariablesAsync(IResource resource) TryAddDependentResources(value); } - WritePortBindingEnvironmentVariables(resource); - Writer.WriteEndObject(); } } @@ -413,26 +411,6 @@ public async Task WriteCommandLineArgumentsAsync(IResource resource) } } - /// - /// Write environment variables for port bindings related to annotations. - /// - /// The which contains annotations. - public void WritePortBindingEnvironmentVariables(IResource resource) - { - if (resource.TryGetEndpoints(out var endpoints)) - { - foreach (var endpoint in endpoints) - { - if (endpoint.EnvironmentVariable is null) - { - continue; - } - - Writer.WriteString(endpoint.EnvironmentVariable, $"{{{resource.Name}.bindings.{endpoint.Name}.targetPort}}"); - } - } - } - internal void WriteDockerBuildArgs(IEnumerable? buildArgs) { if (buildArgs?.ToArray() is { Length: > 0 } args) diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index ee0ab824d3..65ad6a4def 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -35,7 +35,7 @@ public static IResourceBuilder WithEnvironment(this IResourceBuilder bu /// The name of the environment variable. /// The value of the environment variable. /// A resource configured with the specified environment variable. - public static IResourceBuilder WithEnvironment(this IResourceBuilder builder, string name, in ExpressionInterpolatedStringHandler value) + public static IResourceBuilder WithEnvironment(this IResourceBuilder builder, string name, in ReferenceExpression.ExpressionInterpolatedStringHandler value) where T : IResourceWithEnvironment { var expression = value.GetExpression(); @@ -416,9 +416,19 @@ public static IResourceBuilder WithEndpoint(this IResourceBuilder build name: name, port: port, targetPort: targetPort, - env: env, isProxied: isProxied); + // Set the environment variable on the resource + if (env is not null && builder.Resource is IResourceWithEndpoints resourceWithEndpoints and IResourceWithEnvironment) + { + var endpointReference = new EndpointReference(resourceWithEndpoints, annotation); + + builder.WithAnnotation(new EnvironmentCallbackAnnotation(context => + { + context.EnvironmentVariables[env] = endpointReference.Property(EndpointProperty.TargetPort); + })); + } + return builder.WithAnnotation(annotation); } diff --git a/tests/Aspire.Hosting.Tests/Azure/AzureBicepResourceTests.cs b/tests/Aspire.Hosting.Tests/Azure/AzureBicepResourceTests.cs index 77b1e60589..4d53808197 100644 --- a/tests/Aspire.Hosting.Tests/Azure/AzureBicepResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Azure/AzureBicepResourceTests.cs @@ -457,7 +457,7 @@ public async Task WithReferenceAppInsightsSetsEnvironmentVariable() appInsights.Resource.Outputs["appInsightsConnectionString"] = "myinstrumentationkey"; - var serviceA = builder.AddProject("serviceA") + var serviceA = builder.AddProject("serviceA") .WithReference(appInsights); var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(serviceA.Resource); @@ -1513,7 +1513,7 @@ public async Task PublishAsConnectionString() var ai = builder.AddAzureApplicationInsights("ai").PublishAsConnectionString(); var serviceBus = builder.AddAzureServiceBus("servicebus").PublishAsConnectionString(); - var serviceA = builder.AddProject("serviceA") + var serviceA = builder.AddProject("serviceA") .WithReference(ai) .WithReference(serviceBus); @@ -1618,4 +1618,11 @@ param principalType string """; Assert.Equal(expectedBicep, manifest.BicepText); } + + private sealed class ProjectA : IProjectMetadata + { + public string ProjectPath => "projectA"; + + public LaunchSettings LaunchSettings { get; } = new(); + } } diff --git a/tests/Aspire.Hosting.Tests/Dapr/DaprTests.cs b/tests/Aspire.Hosting.Tests/Dapr/DaprTests.cs index aa8573a794..8866f2165e 100644 --- a/tests/Aspire.Hosting.Tests/Dapr/DaprTests.cs +++ b/tests/Aspire.Hosting.Tests/Dapr/DaprTests.cs @@ -50,7 +50,7 @@ public async Task WithDaprSideCarAddsAnnotationAndSidecarResource() foreach (var e in endpoints) { - e.AllocatedEndpoint = new(e, "localhost", ports[e.Name]); + e.AllocatedEndpoint = new(e, "localhost", ports[e.Name], dcpServiceName: e.Name); } var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(container); @@ -67,11 +67,11 @@ public async Task WithDaprSideCarAddsAnnotationAndSidecarResource() "--app-port", "80", "--dapr-grpc-port", - "{{- portForServing \"name-dapr-cli_grpc\" -}}", + "{{- portForServing \"grpc\" -}}", "--dapr-http-port", - "{{- portForServing \"name-dapr-cli_http\" -}}", + "{{- portForServing \"http\" -}}", "--metrics-port", - "{{- portForServing \"name-dapr-cli_metrics\" -}}", + "{{- portForServing \"metrics\" -}}", "--app-channel-address", "localhost" }; diff --git a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs index 29d0e2ba3c..ae83bb8af5 100644 --- a/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/ProjectResourceTests.cs @@ -143,8 +143,7 @@ public void WithLaunchProfileAddsAnnotationToProject() var projectResources = appModel.GetProjectResources(); var resource = Assert.Single(projectResources); - // LaunchProfileAnnotation isn't public, so we just check the type name - Assert.Contains(resource.Annotations, a => a.GetType().Name == "LaunchProfileAnnotation"); + Assert.Contains(resource.Annotations, a => a is LaunchProfileAnnotation); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/WithEndpointTests.cs b/tests/Aspire.Hosting.Tests/WithEndpointTests.cs index 4dac7720a6..2559f427d0 100644 --- a/tests/Aspire.Hosting.Tests/WithEndpointTests.cs +++ b/tests/Aspire.Hosting.Tests/WithEndpointTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -15,14 +16,16 @@ public class WithEndpointTests [Fact] public void WithEndpointInvokesCallback() { - using var testProgram = CreateTestProgram(); - testProgram.ServiceABuilder.WithEndpoint(3000, 1000, name: "mybinding"); - testProgram.ServiceABuilder.WithEndpoint("mybinding", endpoint => - { - endpoint.Port = 2000; - }); + using var builder = TestDistributedApplicationBuilder.Create(); + + var projectA = builder.AddProject("projecta") + .WithEndpoint(3000, 1000, name: "mybinding") + .WithEndpoint("mybinding", endpoint => + { + endpoint.Port = 2000; + }); - var endpoint = testProgram.ServiceABuilder.Resource.Annotations.OfType() + var endpoint = projectA.Resource.Annotations.OfType() .Where(e => string.Equals(e.Name, "mybinding", EndpointAnnotationName)).Single(); Assert.Equal(2000, endpoint.Port); } @@ -32,16 +35,17 @@ public void WithEndpointCallbackDoesNotRunIfEndpointDoesntExistAndCreateIfNotExi { var executed = false; - using var testProgram = CreateTestProgram(); - testProgram.ServiceABuilder.WithEndpoint("mybinding", endpoint => - { - executed = true; - }, - createIfNotExists: false); + using var builder = TestDistributedApplicationBuilder.Create(); + + var projectA = builder.AddProject("projecta") + .WithEndpoint("mybinding", endpoint => + { + executed = true; + }, + createIfNotExists: false); Assert.False(executed); - Assert.True(testProgram.ServiceABuilder.Resource.TryGetAnnotationsOfType(out var annotations)); - Assert.DoesNotContain(annotations, e => string.Equals(e.Name, "mybinding", EndpointAnnotationName)); + Assert.False(projectA.Resource.TryGetAnnotationsOfType(out var annotations)); } [Fact] @@ -49,14 +53,16 @@ public void WithEndpointCallbackRunsIfEndpointDoesntExistAndCreateIfNotExistsIsD { var executed = false; - using var testProgram = CreateTestProgram(); - testProgram.ServiceABuilder.WithEndpoint("mybinding", endpoint => - { - executed = true; - }); + using var builder = TestDistributedApplicationBuilder.Create(); + + var projectA = builder.AddProject("projecta") + .WithEndpoint("mybinding", endpoint => + { + executed = true; + }); Assert.True(executed); - Assert.True(testProgram.ServiceABuilder.Resource.TryGetAnnotationsOfType(out _)); + Assert.True(projectA.Resource.TryGetAnnotationsOfType(out _)); } [Fact] @@ -64,15 +70,16 @@ public void WithEndpointCallbackRunsIfEndpointDoesntExistAndCreateIfNotExistsIsT { var executed = false; - using var testProgram = CreateTestProgram(); - testProgram.ServiceABuilder.WithEndpoint("mybinding", endpoint => + using var builder = TestDistributedApplicationBuilder.Create(); + + var projectA = builder.AddProject("projecta").WithEndpoint("mybinding", endpoint => { executed = true; }, createIfNotExists: true); Assert.True(executed); - Assert.True(testProgram.ServiceABuilder.Resource.TryGetAnnotationsOfType(out _)); + Assert.True(projectA.Resource.TryGetAnnotationsOfType(out _)); } [Fact] @@ -80,9 +87,11 @@ public void EndpointsWithTwoPortsSameNameThrows() { var ex = Assert.Throws(() => { - using var testProgram = CreateTestProgram(); - testProgram.ServiceABuilder.WithHttpsEndpoint(3000, 1000, name: "mybinding"); - testProgram.ServiceABuilder.WithHttpsEndpoint(3000, 2000, name: "mybinding"); + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.AddProject("projecta") + .WithHttpsEndpoint(3000, 1000, name: "mybinding") + .WithHttpsEndpoint(3000, 2000, name: "mybinding"); }); Assert.Equal("Endpoint with name 'mybinding' already exists", ex.Message); @@ -93,34 +102,39 @@ public void EndpointsWithSinglePortSameNameThrows() { var ex = Assert.Throws(() => { - using var testProgram = CreateTestProgram(); - testProgram.ServiceABuilder.WithHttpsEndpoint(1000, name: "mybinding"); - testProgram.ServiceABuilder.WithHttpsEndpoint(2000, name: "mybinding"); + using var builder = TestDistributedApplicationBuilder.Create(); + builder.AddProject("projectb") + .WithHttpsEndpoint(1000, name: "mybinding") + .WithHttpsEndpoint(2000, name: "mybinding"); }); Assert.Equal("Endpoint with name 'mybinding' already exists", ex.Message); } [Fact] - public void CanAddEndpointsWithContainerPortAndEnv() + public async Task CanAddEndpointsWithContainerPortAndEnv() { - using var testProgram = CreateTestProgram(); - testProgram.AppBuilder.AddExecutable("foo", "foo", ".") - .WithHttpEndpoint(targetPort: 3001, name: "mybinding", env: "PORT"); + using var builder = TestDistributedApplicationBuilder.Create(); - var app = testProgram.Build(); + builder.AddExecutable("foo", "foo", ".") + .WithHttpEndpoint(targetPort: 3001, name: "mybinding", env: "PORT"); + + using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var exeResources = appModel.GetExecutableResources(); var resource = Assert.Single(exeResources); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(resource); + Assert.Equal("foo", resource.Name); var endpoints = resource.Annotations.OfType().ToArray(); Assert.Single(endpoints); Assert.Equal("mybinding", endpoints[0].Name); Assert.Equal(3001, endpoints[0].TargetPort); Assert.Equal("http", endpoints[0].UriScheme); - Assert.Equal("PORT", endpoints[0].EnvironmentVariable); + Assert.Equal("3001", config["PORT"]); } [Fact] @@ -436,12 +450,22 @@ public async Task VerifyManifestPortAllocationIsGlobal() Assert.Equal(expectedManifest1, manifests[1].ToString()); } - private static TestProgram CreateTestProgram(string[]? args = null) => TestProgram.Create(args); - - sealed class TestProject : IProjectMetadata + private sealed class TestProject : IProjectMetadata { public string ProjectPath => "projectpath"; public LaunchSettings? LaunchSettings { get; } = new(); } + private sealed class ProjectA : IProjectMetadata + { + public string ProjectPath => "projectA"; + + public LaunchSettings LaunchSettings { get; } = new(); + } + + private sealed class ProjectB : IProjectMetadata + { + public string ProjectPath => "projectB"; + public LaunchSettings LaunchSettings { get; } = new(); + } } diff --git a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs index cd8ab89f08..5538e73ee2 100644 --- a/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs +++ b/tests/Aspire.Hosting.Tests/WithEnvironmentTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; using Xunit; namespace Aspire.Hosting.Tests; @@ -11,105 +12,96 @@ public class WithEnvironmentTests [Fact] public async Task EnvironmentReferencingEndpointPopulatesWithBindingUrl() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); - // Create a binding and its metching annotation (simulating DCP behavior) - testProgram.ServiceABuilder.WithHttpsEndpoint(1000, 2000, "mybinding"); - testProgram.ServiceABuilder.WithEndpoint( - "mybinding", - e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)); + var projectA = builder.AddProject("project") + .WithHttpsEndpoint(port: 1000, targetPort: 2000, "mybinding") + .WithEndpoint("mybinding", e => + { + e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000); + }); - testProgram.ServiceBBuilder.WithEnvironment("myName", testProgram.ServiceABuilder.GetEndpoint("mybinding")); + var projectB = builder.AddProject("projectB") + .WithEnvironment("myName", projectA.GetEndpoint("mybinding")); - testProgram.Build(); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); - - var servicesKeysCount = config.Keys.Count(k => k.StartsWith("myName")); - Assert.Equal(1, servicesKeysCount); - Assert.Contains(config, kvp => kvp.Key == "myName" && kvp.Value == "https://localhost:2000"); + Assert.Equal("https://localhost:2000", config["myName"]); } [Fact] public async Task SimpleEnvironmentWithNameAndValue() { - using var testProgram = CreateTestProgram(); - - testProgram.ServiceABuilder.WithEnvironment("myName", "value"); + using var builder = TestDistributedApplicationBuilder.Create(); - testProgram.Build(); + var project = builder.AddProject("projectA") + .WithEnvironment("myName", "value"); - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceABuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(project.Resource); - var servicesKeysCount = config.Keys.Count(k => k.StartsWith("myName")); - Assert.Equal(1, servicesKeysCount); - Assert.Contains(config, kvp => kvp.Key == "myName" && kvp.Value == "value"); + Assert.Equal("value", config["myName"]); } [Fact] public async Task EnvironmentCallbackPopulatesValueWhenCalled() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); var environmentValue = "value"; - testProgram.ServiceABuilder.WithEnvironment("myName", () => environmentValue); + var projectA = builder.AddProject("projectA").WithEnvironment("myName", () => environmentValue); - testProgram.Build(); environmentValue = "value2"; // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceABuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectA.Resource); - var servicesKeysCount = config.Keys.Count(k => k.StartsWith("myName")); - Assert.Equal(1, servicesKeysCount); - Assert.Contains(config, kvp => kvp.Key == "myName" && kvp.Value == "value2"); + Assert.Equal("value2", config["myName"]); } [Fact] public async Task EnvironmentCallbackPopulatesValueWhenParameterResourceProvided() { - using var testProgram = CreateTestProgram(); - testProgram.AppBuilder.Configuration["Parameters:parameter"] = "MY_PARAMETER_VALUE"; - var parameter = testProgram.AppBuilder.AddParameter("parameter"); + using var builder = TestDistributedApplicationBuilder.Create(); - testProgram.ServiceABuilder.WithEnvironment("MY_PARAMETER", parameter); + builder.Configuration["Parameters:parameter"] = "MY_PARAMETER_VALUE"; - testProgram.Build(); + var parameter = builder.AddParameter("parameter"); - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceABuilder.Resource); + var projectA = builder.AddProject("projectA") + .WithEnvironment("MY_PARAMETER", parameter); - Assert.Contains(config, kvp => kvp.Key == "MY_PARAMETER" && kvp.Value == "MY_PARAMETER_VALUE"); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectA.Resource); + + Assert.Equal("MY_PARAMETER_VALUE", config["MY_PARAMETER"]); } [Fact] public async Task EnvironmentCallbackPopulatesWithExpressionPlaceholderWhenPublishingManifest() { - using var testProgram = CreateTestProgram(); - var parameter = testProgram.AppBuilder.AddParameter("parameter"); + using var builder = TestDistributedApplicationBuilder.Create(); - testProgram.ServiceABuilder.WithEnvironment("MY_PARAMETER", parameter); + var parameter = builder.AddParameter("parameter"); - testProgram.Build(); + var projectA = builder.AddProject("projectA") + .WithEnvironment("MY_PARAMETER", parameter); - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceABuilder.Resource, + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectA.Resource, DistributedApplicationOperation.Publish); - Assert.Contains(config, kvp => kvp.Key == "MY_PARAMETER" && kvp.Value == "{parameter.value}"); + Assert.Equal("{parameter.value}", config["MY_PARAMETER"]); } [Fact] public async Task EnvironmentCallbackThrowsWhenParameterValueMissingInDcpMode() { - using var testProgram = CreateTestProgram(); - var parameter = testProgram.AppBuilder.AddParameter("parameter"); - - testProgram.ServiceABuilder.WithEnvironment("MY_PARAMETER", parameter); + using var builder = TestDistributedApplicationBuilder.Create(); - testProgram.Build(); + var parameter = builder.AddParameter("parameter"); - var annotations = testProgram.ServiceABuilder.Resource.Annotations.OfType(); + var projectA = builder.AddProject("projectA") + .WithEnvironment("MY_PARAMETER", parameter); - var exception = await Assert.ThrowsAsync(async () => await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceABuilder.Resource)); + var exception = await Assert.ThrowsAsync(async () => await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectA.Resource)); Assert.Equal("Parameter resource could not be used because configuration key 'Parameters:parameter' is missing and the Parameter has no default value.", exception.Message); } @@ -117,34 +109,32 @@ public async Task EnvironmentCallbackThrowsWhenParameterValueMissingInDcpMode() [Fact] public async Task ComplexEnvironmentCallbackPopulatesValueWhenCalled() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); var environmentValue = "value"; - testProgram.ServiceABuilder.WithEnvironment((context) => - { - context.EnvironmentVariables["myName"] = environmentValue; - }); + var projectA = builder.AddProject("projectA") + .WithEnvironment(context => + { + context.EnvironmentVariables["myName"] = environmentValue; + }); - testProgram.Build(); environmentValue = "value2"; // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceABuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectA.Resource); - var servicesKeysCount = config.Keys.Count(k => k.StartsWith("myName")); - Assert.Equal(1, servicesKeysCount); - Assert.Contains(config, kvp => kvp.Key == "myName" && kvp.Value == "value2"); + Assert.Equal("value2", config["myName"]); } [Fact] public async Task EnvironmentVariableExpressions() { - var builder = DistributedApplication.CreateBuilder(); + using var builder = TestDistributedApplicationBuilder.Create(); var test = builder.AddResource(new TestResource("test", "connectionString")); var container = builder.AddContainer("container1", "image") - .WithHttpEndpoint(name: "primary") + .WithHttpEndpoint(name: "primary", targetPort: 10005) .WithEndpoint("primary", ep => { ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 90); @@ -155,36 +145,64 @@ public async Task EnvironmentVariableExpressions() var containerB = builder.AddContainer("container2", "imageB") .WithEnvironment("URL", $"{endpoint}/foo") .WithEnvironment("PORT", $"{endpoint.Property(EndpointProperty.Port)}") + .WithEnvironment("TARGET_PORT", $"{endpoint.Property(EndpointProperty.TargetPort)}") .WithEnvironment("HOST", $"{test.Resource};name=1"); var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(containerB.Resource); var manifestConfig = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(containerB.Resource, DistributedApplicationOperation.Publish); - Assert.Equal(3, config.Count); + Assert.Equal(4, config.Count); Assert.Equal($"http://localhost:90/foo", config["URL"]); Assert.Equal("90", config["PORT"]); + Assert.Equal("10005", config["TARGET_PORT"]); Assert.Equal("connectionString;name=1", config["HOST"]); - Assert.Equal(3, manifestConfig.Count); + Assert.Equal(4, manifestConfig.Count); Assert.Equal("{container1.bindings.primary.url}/foo", manifestConfig["URL"]); Assert.Equal("{container1.bindings.primary.port}", manifestConfig["PORT"]); + Assert.Equal("{container1.bindings.primary.targetPort}", manifestConfig["TARGET_PORT"]); Assert.Equal("{test.connectionString};name=1", manifestConfig["HOST"]); } + [Fact] + public async Task EnvironmentVariableWithDynamicTargetPort() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var container = builder.AddContainer("container1", "image") + .WithHttpEndpoint(name: "primary") + .WithEndpoint("primary", ep => + { + ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 90, dcpServiceName: "container1_primary"); + }); + + var endpoint = container.GetEndpoint("primary"); + + var containerB = builder.AddContainer("container2", "imageB") + .WithEnvironment("TARGET_PORT", $"{endpoint.Property(EndpointProperty.TargetPort)}"); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(containerB.Resource); + + var pair = Assert.Single(config); + Assert.Equal("TARGET_PORT", pair.Key); + Assert.Equal("""{{- portForServing "container1_primary" -}}""", pair.Value); + } + [Fact] public async Task EnvironmentWithConnectionStringSetsProperEnvironmentVariable() { // Arrange + using var builder = TestDistributedApplicationBuilder.Create(); + const string sourceCon = "sourceConnectionString"; - using var testProgram = CreateTestProgram(); - var sourceBuilder = testProgram.AppBuilder.AddResource(new TestResource("sourceService", sourceCon)); - var targetBuilder = testProgram.AppBuilder.AddContainer("targetContainer", "targetImage"); + + var sourceBuilder = builder.AddResource(new TestResource("sourceService", sourceCon)); + var targetBuilder = builder.AddContainer("targetContainer", "targetImage"); string envVarName = "CUSTOM_CONNECTION_STRING"; // Act targetBuilder.WithEnvironment(envVarName, sourceBuilder); - testProgram.Build(); // Call environment variable callbacks for the Run operation. var runConfig = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(targetBuilder.Resource, DistributedApplicationOperation.Run); @@ -205,5 +223,16 @@ private sealed class TestResource(string name, string connectionString) : Resour ReferenceExpression.Create($"{connectionString}"); } - private static TestProgram CreateTestProgram(string[]? args = null) => TestProgram.Create(args); + private sealed class ProjectA : IProjectMetadata + { + public string ProjectPath => "projectA"; + + public LaunchSettings LaunchSettings { get; } = new(); + } + + private sealed class ProjectB : IProjectMetadata + { + public string ProjectPath => "projectB"; + public LaunchSettings LaunchSettings { get; } = new(); + } } diff --git a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs index 9113005534..cebaafff24 100644 --- a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.Tests.Utils; +using Aspire.Hosting.Utils; using Xunit; namespace Aspire.Hosting.Tests; @@ -13,167 +14,142 @@ public class WithReferenceTests [InlineData("MYbinding")] public async Task ResourceWithSingleEndpointProducesSimplifiedEnvironmentVariables(string endpointName) { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); // Create a binding and its matching annotation (simulating DCP behavior) - testProgram.ServiceABuilder.WithHttpsEndpoint(1000, 2000, "mybinding"); - testProgram.ServiceABuilder.WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)); + var projectA = builder.AddProject("projecta") + .WithHttpsEndpoint(1000, 2000, "mybinding") + .WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)); // Get the service provider. - testProgram.ServiceBBuilder.WithReference(testProgram.ServiceABuilder.GetEndpoint(endpointName)); - testProgram.Build(); + var projectB = builder.AddProject("b").WithReference(projectA.GetEndpoint(endpointName)); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); - var servicesKeysCount = config.Keys.Count(k => k.StartsWith("services__")); - Assert.Equal(1, servicesKeysCount); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__mybinding__0" && kvp.Value == "https://localhost:2000"); + Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); } [Fact] public async Task ResourceWithConflictingEndpointsProducesFullyScopedEnvironmentVariables() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); - // Create a binding and its matching annotation (simulating DCP behavior) - testProgram.ServiceABuilder.WithHttpsEndpoint(1000, 2000, "mybinding"); - testProgram.ServiceABuilder.WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)); + var projectA = builder.AddProject("projecta") + .WithHttpsEndpoint(1000, 2000, "mybinding") + .WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)) + .WithHttpsEndpoint(1000, 3000, "myconflictingbinding") + // Create a binding and its matching annotation (simulating DCP behavior) - HOWEVER + // this binding conflicts with the earlier because they have the same scheme. + .WithEndpoint("myconflictingbinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 3000)); - // Create a binding and its matching annotation (simulating DCP behavior) - HOWEVER - // this binding conflicts with the earlier because they have the same scheme. - testProgram.ServiceABuilder.WithHttpsEndpoint(1000, 3000, "myconflictingbinding"); - testProgram.ServiceABuilder.WithEndpoint("myconflictingbinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 3000)); - testProgram.ServiceBBuilder.WithReference(testProgram.ServiceABuilder.GetEndpoint("mybinding")); - testProgram.ServiceBBuilder.WithReference(testProgram.ServiceABuilder.GetEndpoint("myconflictingbinding")); - - // Get the service provider. - testProgram.Build(); + var projectB = builder.AddProject("projectb") + .WithReference(projectA.GetEndpoint("mybinding")) + .WithReference(projectA.GetEndpoint("myconflictingbinding")); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); - var servicesKeysCount = config.Keys.Count(k => k.StartsWith("services__")); - Assert.Equal(2, servicesKeysCount); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__mybinding__0" && kvp.Value == "https://localhost:2000"); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__myconflictingbinding__0" && kvp.Value == "https://localhost:3000"); + Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); + Assert.Equal("https://localhost:3000", config["services__projecta__myconflictingbinding__0"]); } [Fact] public async Task ResourceWithNonConflictingEndpointsProducesAllVariantsOfEnvironmentVariables() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); // Create a binding and its matching annotation (simulating DCP behavior) - testProgram.ServiceABuilder.WithHttpsEndpoint(1000, 2000, "mybinding"); - testProgram.ServiceABuilder.WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)); - - // Create a binding and its matching annotation (simulating DCP behavior) - not - // conflicting because the scheme is different to the first binding. - testProgram.ServiceABuilder.WithHttpEndpoint(1000, 3000, "mynonconflictingbinding"); - testProgram.ServiceABuilder.WithEndpoint("mynonconflictingbinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 3000)); - - testProgram.ServiceBBuilder.WithReference(testProgram.ServiceABuilder.GetEndpoint("mybinding")); - testProgram.ServiceBBuilder.WithReference(testProgram.ServiceABuilder.GetEndpoint("mynonconflictingbinding")); - - // Get the service provider. - testProgram.Build(); + var projectA = builder.AddProject("projecta") + .WithHttpsEndpoint(1000, 2000, "mybinding") + .WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)) + // Create a binding and its matching annotation (simulating DCP behavior) - not + // conflicting because the scheme is different to the first binding. + .WithHttpEndpoint(1000, 3000, "mynonconflictingbinding") + .WithEndpoint("mynonconflictingbinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 3000)); + + var projectB = builder.AddProject("projectb") + .WithReference(projectA.GetEndpoint("mybinding")) + .WithReference(projectA.GetEndpoint("mynonconflictingbinding")); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); - var servicesKeysCount = config.Keys.Count(k => k.StartsWith("services__")); - Assert.Equal(2, servicesKeysCount); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__mybinding__0" && kvp.Value == "https://localhost:2000"); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__mynonconflictingbinding__0" && kvp.Value == "http://localhost:3000"); + Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); + Assert.Equal("http://localhost:3000", config["services__projecta__mynonconflictingbinding__0"]); } [Fact] public async Task ResourceWithConflictingEndpointsProducesAllEnvironmentVariables() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); // Create a binding and its matching annotation (simulating DCP behavior) - testProgram.ServiceABuilder.WithHttpsEndpoint(1000, 2000, "mybinding"); - testProgram.ServiceABuilder.WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)); - - testProgram.ServiceABuilder.WithHttpsEndpoint(1000, 3000, "mybinding2"); - testProgram.ServiceABuilder.WithEndpoint("mybinding2", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 3000)); - - // The launch profile adds an "http" endpoint - testProgram.ServiceABuilder.WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 4000)); + var projectA = builder.AddProject("projecta") + .WithHttpsEndpoint(1000, 2000, "mybinding") + .WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)) + .WithHttpsEndpoint(1000, 3000, "mybinding2") + .WithEndpoint("mybinding2", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 3000)); // Get the service provider. - testProgram.ServiceBBuilder.WithReference(testProgram.ServiceABuilder); - testProgram.Build(); + var projectB = builder.AddProject("projectb") + .WithReference(projectA); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); - var servicesKeysCount = config.Keys.Count(k => k.StartsWith("services__")); - Assert.Equal(3, servicesKeysCount); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__mybinding__0" && kvp.Value == "https://localhost:2000"); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__mybinding2__0" && kvp.Value == "https://localhost:3000"); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__http__0" && kvp.Value == "http://localhost:4000"); + Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); + Assert.Equal("https://localhost:3000", config["services__projecta__mybinding2__0"]); } [Fact] public async Task ResourceWithEndpointsProducesAllEnvironmentVariables() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); - // Create a binding and its metching annotation (simulating DCP behavior) - testProgram.ServiceABuilder.WithHttpsEndpoint(1000, 2000, "mybinding"); - testProgram.ServiceABuilder.WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)); - - testProgram.ServiceABuilder.WithHttpEndpoint(1000, 3000, "mybinding2"); - testProgram.ServiceABuilder.WithEndpoint("mybinding2", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 3000)); - - // The launch profile adds an "http" endpoint - testProgram.ServiceABuilder.WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 4000)); + var projectA = builder.AddProject("projecta") + .WithHttpsEndpoint(1000, 2000, "mybinding") + .WithEndpoint("mybinding", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)) + .WithHttpEndpoint(1000, 3000, "mybinding2") + .WithEndpoint("mybinding2", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 3000)); // Get the service provider. - testProgram.ServiceBBuilder.WithReference(testProgram.ServiceABuilder); - testProgram.Build(); - + var projectB = builder.AddProject("projectb") + .WithReference(projectA); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); - var servicesKeysCount = config.Keys.Count(k => k.StartsWith("services__")); - Assert.Equal(3, servicesKeysCount); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__mybinding__0" && kvp.Value == "https://localhost:2000"); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__mybinding2__0" && kvp.Value == "http://localhost:3000"); - Assert.Contains(config, kvp => kvp.Key == "services__servicea__http__0" && kvp.Value == "http://localhost:4000"); + Assert.Equal("https://localhost:2000", config["services__projecta__mybinding__0"]); + Assert.Equal("http://localhost:3000", config["services__projecta__mybinding2__0"]); } [Fact] public async Task ConnectionStringResourceThrowsWhenMissingConnectionString() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); // Get the service provider. - var resource = testProgram.AppBuilder.AddResource(new TestResource("resource")); - testProgram.ServiceBBuilder.WithReference(resource, optional: false); - testProgram.Build(); + var resource = builder.AddResource(new TestResource("resource")); + var projectB = builder.AddProject("projectb").WithReference(resource, optional: false); // Call environment variable callbacks. await Assert.ThrowsAsync(async () => { - await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); }); } [Fact] public async Task ConnectionStringResourceOptionalWithMissingConnectionString() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); // Get the service provider. - var resource = testProgram.AppBuilder.AddResource(new TestResource("resource")); - testProgram.ServiceBBuilder.WithReference(resource, optional: true); - testProgram.Build(); + var resource = builder.AddResource(new TestResource("resource")); + var projectB = builder.AddProject("projectB") + .WithReference(resource, optional: true); - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); var servicesKeysCount = config.Keys.Count(k => k.StartsWith("ConnectionStrings__")); Assert.Equal(0, servicesKeysCount); @@ -182,17 +158,17 @@ public async Task ConnectionStringResourceOptionalWithMissingConnectionString() [Fact] public async Task ParameterAsConnectionStringResourceThrowsWhenConnectionStringSectionMissing() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); // Get the service provider. - var missingResource = testProgram.AppBuilder.AddConnectionString("missingresource"); - testProgram.ServiceBBuilder.WithReference(missingResource); - testProgram.Build(); + var missingResource = builder.AddConnectionString("missingresource"); + var projectB = builder.AddProject("projectb") + .WithReference(missingResource); // Call environment variable callbacks. var exception = await Assert.ThrowsAsync(async () => { - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); }); Assert.Equal("Connection string parameter resource could not be used because connection string 'missingresource' is missing.", exception.Message); @@ -201,16 +177,17 @@ public async Task ParameterAsConnectionStringResourceThrowsWhenConnectionStringS [Fact] public async Task ParameterAsConnectionStringResourceInjectsConnectionStringWhenPresent() { - using var testProgram = CreateTestProgram(); - testProgram.AppBuilder.Configuration["ConnectionStrings:resource"] = "test connection string"; + using var builder = TestDistributedApplicationBuilder.Create(); + + builder.Configuration["ConnectionStrings:resource"] = "test connection string"; // Get the service provider. - var resource = testProgram.AppBuilder.AddConnectionString("resource"); - testProgram.ServiceBBuilder.WithReference(resource); - testProgram.Build(); + var resource = builder.AddConnectionString("resource"); + var projectB = builder.AddProject("projectb") + .WithReference(resource); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); Assert.Equal("test connection string", config["ConnectionStrings__resource"]); } @@ -218,15 +195,15 @@ public async Task ParameterAsConnectionStringResourceInjectsConnectionStringWhen [Fact] public async Task ParameterAsConnectionStringResourceInjectsExpressionWhenPublishingManifest() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); // Get the service provider. - var resource = testProgram.AppBuilder.AddConnectionString("resource"); - testProgram.ServiceBBuilder.WithReference(resource); - testProgram.Build(); + var resource = builder.AddConnectionString("resource"); + var projectB = builder.AddProject("projectb") + .WithReference(resource); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource, DistributedApplicationOperation.Publish); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Publish); Assert.Equal("{resource.connectionString}", config["ConnectionStrings__resource"]); } @@ -234,15 +211,15 @@ public async Task ParameterAsConnectionStringResourceInjectsExpressionWhenPublis [Fact] public async Task ParameterAsConnectionStringResourceInjectsCorrectEnvWhenPublishingManifest() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); // Get the service provider. - var resource = testProgram.AppBuilder.AddConnectionString("resource", "MY_ENV"); - testProgram.ServiceBBuilder.WithReference(resource); - testProgram.Build(); + var resource = builder.AddConnectionString("resource", "MY_ENV"); + var projectB = builder.AddProject("projectb") + .WithReference(resource); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource, DistributedApplicationOperation.Publish); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource, DistributedApplicationOperation.Publish); Assert.Equal("{resource.connectionString}", config["MY_ENV"]); } @@ -250,18 +227,18 @@ public async Task ParameterAsConnectionStringResourceInjectsCorrectEnvWhenPublis [Fact] public async Task ConnectionStringResourceWithConnectionString() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); // Get the service provider. - var resource = testProgram.AppBuilder.AddResource(new TestResource("resource") + var resource = builder.AddResource(new TestResource("resource") { ConnectionString = "123" }); - testProgram.ServiceBBuilder.WithReference(resource); - testProgram.Build(); + var projectB = builder.AddProject("projectb") + .WithReference(resource); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); var servicesKeysCount = config.Keys.Count(k => k.StartsWith("ConnectionStrings__")); Assert.Equal(1, servicesKeysCount); @@ -271,18 +248,19 @@ public async Task ConnectionStringResourceWithConnectionString() [Fact] public async Task ConnectionStringResourceWithConnectionStringOverwriteName() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); // Get the service provider. - var resource = testProgram.AppBuilder.AddResource(new TestResource("resource") + var resource = builder.AddResource(new TestResource("resource") { ConnectionString = "123" }); - testProgram.ServiceBBuilder.WithReference(resource, connectionName: "bob"); - testProgram.Build(); + + var projectB = builder.AddProject("projectb") + .WithReference(resource, connectionName: "bob"); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceBBuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectB.Resource); var servicesKeysCount = config.Keys.Count(k => k.StartsWith("ConnectionStrings__")); Assert.Equal(1, servicesKeysCount); @@ -292,36 +270,35 @@ public async Task ConnectionStringResourceWithConnectionStringOverwriteName() [Fact] public void WithReferenceHttpRelativeUriThrowsException() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); - Assert.Throws(() => testProgram.ServiceABuilder.WithReference("petstore", new Uri("petstore.swagger.io", UriKind.Relative))); + Assert.Throws(() => builder.AddProject("projecta").WithReference("petstore", new Uri("petstore.swagger.io", UriKind.Relative))); } [Fact] public void WithReferenceHttpUriThrowsException() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); - Assert.Throws(() => testProgram.ServiceABuilder.WithReference("petstore", new Uri("https://petstore.swagger.io/v2"))); + Assert.Throws(() => builder.AddProject("projecta").WithReference("petstore", new Uri("https://petstore.swagger.io/v2"))); } [Fact] public async Task WithReferenceHttpProduceEnvironmentVariables() { - using var testProgram = CreateTestProgram(); + using var builder = TestDistributedApplicationBuilder.Create(); - testProgram.ServiceABuilder.WithReference("petstore", new Uri("https://petstore.swagger.io/")); + var projectA = builder.AddProject("projecta") + .WithReference("petstore", new Uri("https://petstore.swagger.io/")); // Call environment variable callbacks. - var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(testProgram.ServiceABuilder.Resource); + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(projectA.Resource); var servicesKeysCount = config.Keys.Count(k => k.StartsWith("services__")); Assert.Equal(1, servicesKeysCount); Assert.Contains(config, kvp => kvp.Key == "services__petstore" && kvp.Value == "https://petstore.swagger.io/"); } - private static TestProgram CreateTestProgram(string[]? args = null) => TestProgram.Create(args); - private sealed class TestResource(string name) : Resource(name), IResourceWithConnectionString { public string? ConnectionString { get; set; } @@ -329,4 +306,17 @@ private sealed class TestResource(string name) : Resource(name), IResourceWithCo public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{ConnectionString}"); } + + private sealed class ProjectA : IProjectMetadata + { + public string ProjectPath => "projectA"; + + public LaunchSettings LaunchSettings { get; } = new(); + } + + private sealed class ProjectB : IProjectMetadata + { + public string ProjectPath => "projectB"; + public LaunchSettings LaunchSettings { get; } = new(); + } }