Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Annotation that holds a filter that determines if environment variables should be injected for a given endpoint.
/// </summary>
internal class EndpointEnvironmentInjectionFilterAnnotation(Func<EndpointAnnotation, bool> filter) : IResourceAnnotation
{
public Func<EndpointAnnotation, bool> Filter { get; } = filter;
}
18 changes: 17 additions & 1 deletion src/Aspire.Hosting/ApplicationModel/ProjectResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,28 @@ namespace Aspire.Hosting.ApplicationModel;
/// <param name="name">The name of the resource.</param>
public class ProjectResource(string name) : Resource(name), IResourceWithEnvironment, IResourceWithArgs, IResourceWithServiceDiscovery
{
// Map endpoint annotations to the kestrel config hosts we created them for
// Keep track of the config host for each Kestrel endpoint annotation
internal Dictionary<EndpointAnnotation, string> KestrelEndpointAnnotationHosts { get; } = new();

// Are there any endpoints coming from Kestrel configuration
internal bool HasKestrelEndpoints => KestrelEndpointAnnotationHosts.Count > 0;

// Track the https endpoint that was added as a default, and should be excluded from the port & kestrel environment
internal EndpointAnnotation? DefaultHttpsEndpoint { get; set; }

internal bool ShouldInjectEndpointEnvironment(EndpointReference e)
{
var endpoint = e.EndpointAnnotation;

if (endpoint.UriScheme is not ("http" or "https") || // Only process http and https endpoints
endpoint.TargetPortEnvironmentVariable is not null) // Skip if target port env variable was set
{
return false;
}

// If any filter rejects the endpoint, skip it
return !Annotations.OfType<EndpointEnvironmentInjectionFilterAnnotation>()
.Select(a => a.Filter)
.Any(f => !f(endpoint));
}
}
25 changes: 18 additions & 7 deletions src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,6 @@ private static IResourceBuilder<ProjectResource> WithProjectDefaults(this IResou
{
builder.WithEndpoint(schemeAsEndpointName ?? endpoint.EndpointName, e =>
{

if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
{
// In Publish mode, we could not set the Port because it needs to be the standard
Expand Down Expand Up @@ -557,6 +556,21 @@ public static IResourceBuilder<ProjectResource> DisableForwardedHeaders(this IRe
return builder;
}

/// <summary>
/// Set a filter that determines if environment variables are injected for a given endpoint.
/// By default, all endpoints are included (if this method is not called).
/// </summary>
/// <param name="builder">The project resource builder.</param>
/// <param name="filter">The filter callback that returns true if and only if the endpoint should be included.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<ProjectResource> WithEndpointsInEnvironment(
this IResourceBuilder<ProjectResource> builder, Func<EndpointAnnotation, bool> filter)
{
builder.Resource.Annotations.Add(new EndpointEnvironmentInjectionFilterAnnotation(filter));

return builder;
}

private static IConfiguration GetConfiguration(ProjectResource projectResource)
{
var projectMetadata = projectResource.GetProjectMetadata();
Expand All @@ -578,9 +592,6 @@ private static IConfiguration GetConfiguration(ProjectResource projectResource)
return configBuilder.Build();
}

static bool IsValidAspNetCoreUrl(EndpointAnnotation e) =>
e.UriScheme is "http" or "https" && e.TargetPortEnvironmentVariable is null;

private static void SetAspNetCoreUrls(this IResourceBuilder<ProjectResource> builder)
{
builder.WithEnvironment(context =>
Expand All @@ -597,7 +608,7 @@ private static void SetAspNetCoreUrls(this IResourceBuilder<ProjectResource> bui
var first = true;

// Turn http and https endpoints into a single ASPNETCORE_URLS environment variable.
foreach (var e in builder.Resource.GetEndpoints().Where(e => IsValidAspNetCoreUrl(e.EndpointAnnotation)))
foreach (var e in builder.Resource.GetEndpoints().Where(builder.Resource.ShouldInjectEndpointEnvironment))
{
if (!first)
{
Expand Down Expand Up @@ -646,7 +657,7 @@ private static void SetOnePortsEnvVariable(this IResourceBuilder<ProjectResource
var firstPort = true;

// Turn endpoint ports into a single environment variable
foreach (var e in builder.Resource.GetEndpoints().Where(e => IsValidAspNetCoreUrl(e.EndpointAnnotation)))
foreach (var e in builder.Resource.GetEndpoints().Where(builder.Resource.ShouldInjectEndpointEnvironment))
{
// Skip the default https endpoint because the container likely won't be set up to listen on https (e.g. ACA case)
if (e.EndpointAnnotation.UriScheme == scheme && e.EndpointAnnotation != builder.Resource.DefaultHttpsEndpoint)
Expand Down Expand Up @@ -677,7 +688,7 @@ private static void SetKestrelUrlOverrideEnvVariables(this IResourceBuilder<Proj
// don't come from Kestrel. This is because having Kestrel endpoints overrides everything
if (builder.Resource.HasKestrelEndpoints)
{
foreach (var e in builder.Resource.GetEndpoints().Where(e => IsValidAspNetCoreUrl(e.EndpointAnnotation)))
foreach (var e in builder.Resource.GetEndpoints().Where(builder.Resource.ShouldInjectEndpointEnvironment))
{
// Skip the default https endpoint because the container likely won't be set up to listen on https (e.g. ACA case)
if (e.EndpointAnnotation == builder.Resource.DefaultHttpsEndpoint)
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Aspire.Hosting.ApplicationModel.ResourceNotificationService.ResourceNotificationService(Microsoft.Extensions.Logging.ILogger<Aspire.Hosting.ApplicationModel.ResourceNotificationService!>! logger, Microsoft.Extensions.Hosting.IHostApplicationLifetime! hostApplicationLifetime) -> void
Aspire.Hosting.ApplicationModel.ResourceNotificationService.WaitForResourceAsync(string! resourceName, string? targetState = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
Aspire.Hosting.ApplicationModel.ResourceNotificationService.WaitForResourceAsync(string! resourceName, System.Collections.Generic.IEnumerable<string!>! targetStates, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<string!>!
static Aspire.Hosting.ProjectResourceBuilderExtensions.WithEndpointsInEnvironment(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.ProjectResource!>! builder, System.Func<Aspire.Hosting.ApplicationModel.EndpointAnnotation!, bool>! filter) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.ProjectResource!>!
Aspire.Hosting.DistributedApplicationExecutionContext.DistributedApplicationExecutionContext(Aspire.Hosting.DistributedApplicationExecutionContextOptions! options) -> void
Aspire.Hosting.DistributedApplicationExecutionContext.ServiceProvider.get -> System.IServiceProvider!
Aspire.Hosting.DistributedApplicationExecutionContextOptions
Expand Down
7 changes: 7 additions & 0 deletions tests/Aspire.Hosting.Tests/Dcp/ApplicationExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,9 @@ public async Task EndpointPortsProjectNoPortNoTargetPort()

builder.AddProject<Projects.ServiceA>("ServiceA")
.WithEndpoint(name: "NoPortNoTargetPort", env: "NO_PORT_NO_TARGET_PORT", isProxied: true)
.WithHttpEndpoint(name: "hp1", port: 5001)
.WithHttpEndpoint(name: "dontinjectme", port: 5002)
.WithEndpointsInEnvironment(e => e.Name != "dontinjectme")
.WithReplicas(3);

var kubernetesService = new TestKubernetesService();
Expand All @@ -376,6 +379,10 @@ public async Task EndpointPortsProjectNoPortNoTargetPort()
var envVarVal = ers.Spec.Template.Spec.Env?.Single(v => v.Name == "NO_PORT_NO_TARGET_PORT").Value;
Assert.False(string.IsNullOrWhiteSpace(envVarVal));
Assert.Contains("""portForServing "ServiceA-NoPortNoTargetPort" """, envVarVal);

// ASPNETCORE_URLS should not include dontinjectme, as it was excluded using WithEndpointsInEnvironment
var aspnetCoreUrls = ers.Spec.Template.Spec.Env?.Single(v => v.Name == "ASPNETCORE_URLS").Value;
Assert.Equal("http://localhost:{{- portForServing \"ServiceA-http\" -}};http://localhost:{{- portForServing \"ServiceA-hp1\" -}}", aspnetCoreUrls);
}

[Fact]
Expand Down
31 changes: 31 additions & 0 deletions tests/Aspire.Hosting.Tests/KestrelConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,37 @@ public async Task VerifyMultipleKestrelEndpointsManifestGeneration()
Assert.Equal(expectedManifest, manifest.ToString());
}

[Fact]
public async Task VerifyKestrelEnvVariablesGetOmittedFromManifestIfExcluded()
{
var resource = CreateTestProjectResource<ProjectWithMultipleHttpKestrelEndpoints>(
operation: DistributedApplicationOperation.Publish,
callback: builder =>
{
builder.WithHttpEndpoint(5017, name: "ExplicitProxiedHttp")
.WithHttpEndpoint(5018, name: "ExplicitNoProxyHttp", isProxied: false)
// Exclude both a Kestrel endpoint and an explicit endpoint from environment injection
// We do it as separate filters to ensure they are combined correctly
.WithEndpointsInEnvironment(e => e.Name != "FirstHttpEndpoint")
.WithEndpointsInEnvironment(e => e.Name != "ExplicitProxiedHttp");
});

var manifest = await ManifestUtils.GetManifest(resource);

var expectedEnv = """
{
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES": "true",
"OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY": "in_memory",
"ASPNETCORE_FORWARDEDHEADERS_ENABLED": "true",
"Kestrel__Endpoints__SecondHttpEndpoint__Url": "http://*:{projectName.bindings.SecondHttpEndpoint.targetPort}",
"Kestrel__Endpoints__ExplicitNoProxyHttp__Url": "http://*:{projectName.bindings.ExplicitNoProxyHttp.targetPort}"
}
""";

Assert.Equal(expectedEnv, manifest["env"]!.ToString());
}

[Fact]
public async Task VerifyEndpointLevelKestrelProtocol()
{
Expand Down
7 changes: 7 additions & 0 deletions tests/Aspire.Hosting.Tests/ProjectResourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@ public async Task ExcludeLaunchProfileAddsHttpOrHttpsEndpointAddsToEnv()
.WithHttpEndpoint(port: 5000, name: "http")
.WithHttpsEndpoint(port: 5001, name: "https")
.WithHttpEndpoint(port: 5002, name: "http2", env: "SOME_ENV")
.WithHttpEndpoint(port: 5003, name: "dontinjectme")
// Should not be included in ASPNETCORE_URLS
.WithEndpointsInEnvironment(filter: e => e.Name != "dontinjectme")
.WithEndpoint("http", e =>
{
e.AllocatedEndpoint = new(e, "localhost", e.Port!.Value, targetPortExpression: "p0");
Expand All @@ -299,6 +302,10 @@ public async Task ExcludeLaunchProfileAddsHttpOrHttpsEndpointAddsToEnv()
.WithEndpoint("http2", e =>
{
e.AllocatedEndpoint = new(e, "localhost", e.Port!.Value, targetPortExpression: "p2");
})
.WithEndpoint("dontinjectme", e =>
{
e.AllocatedEndpoint = new(e, "localhost", e.Port!.Value, targetPortExpression: "p3");
});

using var app = appBuilder.Build();
Expand Down
5 changes: 4 additions & 1 deletion tests/Aspire.Hosting.Tests/WithEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -523,11 +523,14 @@ public async Task VerifyManifestProjectWithEndpointsSetsPortsEnvVariables()
.WithHttpEndpoint(name: "hp2", port: 5002, targetPort: 5003)
.WithHttpEndpoint(name: "hp3", targetPort: 5004)
.WithHttpEndpoint(name: "hp4")
.WithHttpEndpoint(name: "dontinjectme")
.WithHttpsEndpoint()
.WithHttpsEndpoint(name: "hps1", port: 7001)
.WithHttpsEndpoint(name: "hps2", port: 7002, targetPort: 7003)
.WithHttpsEndpoint(name: "hps3", targetPort: 7004)
.WithHttpsEndpoint(name: "hps4", targetPort: 7005);
.WithHttpsEndpoint(name: "hps4", targetPort: 7005)
// Should not be included in HTTP_PORTS
.WithEndpointsInEnvironment(e => e.Name != "dontinjectme");

var manifest = await ManifestUtils.GetManifest(project.Resource);

Expand Down