Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 134 additions & 70 deletions src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions src/Aspire.Hosting.Kubernetes/KubernetesGatewayExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@ public static IResourceBuilder<KubernetesGatewayResource> WithGatewayClass(
}

/// <summary>
/// Adds a path-based routing rule to the gateway. The rule matches all hosts and routes
/// traffic matching the specified path to the given endpoint's backing Kubernetes service.
/// This generates an <c>HTTPRoute</c> resource attached to the Gateway.
/// Adds a path-based routing rule to the gateway. The rule matches each hostname configured
/// with <see cref="WithHostname(IResourceBuilder{KubernetesGatewayResource}, string)"/>, or all
/// hosts when no hostname is configured, and routes matching traffic to the endpoint's backing
/// Kubernetes service. This generates an <c>HTTPRoute</c> resource attached to the Gateway.
/// </summary>
/// <param name="builder">The gateway resource builder.</param>
/// <param name="path">The URL path to match (e.g., <c>"/"</c> or <c>"/api"</c>). Must start with <c>/</c>.</param>
Expand Down Expand Up @@ -165,8 +166,9 @@ public static IResourceBuilder<KubernetesGatewayResource> WithRoute(

/// <summary>
/// Adds a hostname that this gateway's routes match. Multiple hostnames can be added by calling
/// this method repeatedly. Hostnames are used as <c>hostnames</c> in generated <c>HTTPRoute</c>
/// resources and as HTTPS listener hostnames when TLS is configured.
/// this method repeatedly. Routes without an explicit host apply to each configured hostname.
/// Hostnames are used as <c>hostnames</c> in generated <c>HTTPRoute</c> resources and as HTTPS
/// listener hostnames when TLS is configured.
/// </summary>
/// <param name="builder">The gateway resource builder.</param>
/// <param name="hostname">The hostname to match (e.g., <c>"api.example.com"</c>).</param>
Expand Down
12 changes: 9 additions & 3 deletions src/Aspire.Hosting.Kubernetes/KubernetesIngressExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,9 @@ public static IResourceBuilder<KubernetesIngressResource> WithIngressClass(
}

/// <summary>
/// Adds a path-based rule to the ingress. The rule matches all hosts and forwards
/// traffic matching the specified path to the given endpoint's backing Kubernetes service.
/// Adds a path-based rule to the ingress. The rule matches each hostname configured with
/// <see cref="WithHostname(IResourceBuilder{KubernetesIngressResource}, string)"/>, or all hosts
/// when no hostname is configured, and forwards matching traffic to the endpoint's backing Kubernetes service.
/// </summary>
/// <param name="builder">The ingress resource builder.</param>
/// <param name="path">The URL path to match (e.g., <c>"/"</c> or <c>"/api"</c>). Must start with <c>/</c>.</param>
Expand Down Expand Up @@ -185,7 +186,8 @@ public static IResourceBuilder<KubernetesIngressResource> WithPath(

/// <summary>
/// Adds a hostname that this ingress matches. Multiple hostnames can be added by calling
/// this method repeatedly. If no hostnames are configured, the ingress matches all hosts.
/// this method repeatedly. Path rules without an explicit host apply to each configured
/// hostname. If no hostnames are configured, those rules match all hosts.
/// </summary>
/// <param name="builder">The ingress resource builder.</param>
/// <param name="hostname">The hostname to match (e.g., <c>"api.example.com"</c>).</param>
Expand Down Expand Up @@ -300,6 +302,10 @@ public static IResourceBuilder<KubernetesIngressResource> WithTls(
/// <param name="endpoint">The endpoint reference identifying the default backend service and port.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{KubernetesIngressResource}"/> for chaining.</returns>
/// <ats-returns>The resource builder.</ats-returns>
/// <remarks>
/// Kubernetes default backends remain catch-all even when hostnames are configured. Use host-specific
/// path rules when traffic must be restricted by hostname.
/// </remarks>
[AspireExport]
public static IResourceBuilder<KubernetesIngressResource> WithDefaultBackend(
this IResourceBuilder<KubernetesIngressResource> builder,
Expand Down
151 changes: 151 additions & 0 deletions tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,157 @@ public async Task AddGateway_WithHostRoute_GeneratesHostnameInHttpRoute()
Assert.Contains("HTTPRoute", content);
}

[Fact]
public async Task AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);

var hostname = builder.AddParameter("hostname", "localhost");
var k8s = builder.AddKubernetesEnvironment("env");
var gateway = k8s.AddGateway("public")
.WithGatewayClass("nginx")
.WithHostname(hostname)
.WithTls();

var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();

gateway.WithRoute("/api", api.GetEndpoint("http"));

using var app = builder.Build();
app.Run();

var gatewayPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var routePath = Path.Combine(workspace.Path, "templates", "public", "route.yaml");
var valuesPath = Path.Combine(workspace.Path, "values.yaml");

await Verify(File.ReadAllText(gatewayPath), "yaml")
.AppendContentAsFile(File.ReadAllText(routePath), "yaml")
.AppendContentAsFile(File.ReadAllText(valuesPath), "yaml");
}

[Fact]
public async Task AddGateway_WithHostname_AppliesToHostlessRoute()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);

var k8s = builder.AddKubernetesEnvironment("env");
var gateway = k8s.AddGateway("public")
.WithGatewayClass("nginx")
.WithHostname("api.example.com")
.WithHostname("www.example.com");

var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();

gateway.WithRoute("/api", api.GetEndpoint("http"));

using var app = builder.Build();
app.Run();

var routePath = Path.Combine(workspace.Path, "templates", "public", "route.yaml");

await Verify(File.ReadAllText(routePath), "yaml");
}

[Fact]
public void AddGateway_WithRoute_InheritsMaximumSupportedHostnames()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);

var k8s = builder.AddKubernetesEnvironment("env");
var gateway = k8s.AddGateway("public").WithGatewayClass("test");
var expectedHostnames = Enumerable.Range(1, 16)
.Select(index => $"host-{index}.example.com")
.ToArray();

foreach (var hostname in expectedHostnames)
{
gateway.WithHostname(hostname);
}

var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();

gateway.WithRoute("/", api.GetEndpoint("http"));

using var app = builder.Build();
app.Run();

var route = Assert.Single(gateway.Resource.GeneratedHttpRoutes);
Assert.Equal(expectedHostnames, route.Spec.Hostnames);
}

[Fact]
public void AddGateway_WithRoute_ExceedingMaximumSupportedHostnames_ThrowsOnPublish()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);

var k8s = builder.AddKubernetesEnvironment("env");
var gateway = k8s.AddGateway("public").WithGatewayClass("test");

foreach (var index in Enumerable.Range(1, 17))
{
gateway.WithHostname($"host-{index}.example.com");
}

var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();

gateway.WithRoute("/", api.GetEndpoint("http"));

using var app = builder.Build();
var aggregate = Assert.Throws<AggregateException>(app.Run);
var exception = aggregate.Flatten().InnerExceptions
.Select(e => e.InnerException)
.OfType<InvalidOperationException>()
.First(e => e.Message.StartsWith("Gateway 'public'", StringComparison.Ordinal));

Assert.Equal(
"Gateway 'public' configures 17 hostnames that would be inherited by a hostless route, " +
"but Kubernetes Gateway API HTTPRoute.spec.hostnames supports at most 16 entries. " +
"Define explicit host-scoped routes with WithRoute(hostname, path, endpoint) so each HTTPRoute stays within the limit. " +
"See the Kubernetes Gateway API documentation: https://gateway-api.sigs.k8s.io/reference/api-spec/main/spec/#httproutespec",
exception.Message);
}

[Fact]
public void AddGateway_WithHostRoutes_DoesNotApplyInheritedHostnameLimit()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);

var k8s = builder.AddKubernetesEnvironment("env");
var gateway = k8s.AddGateway("public").WithGatewayClass("test");
var hostnames = Enumerable.Range(1, 17)
.Select(index => $"host-{index}.example.com")
.ToArray();

var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();

foreach (var hostname in hostnames)
{
gateway.WithHostname(hostname);
gateway.WithRoute(hostname, "/", api.GetEndpoint("http"));
}

using var app = builder.Build();
app.Run();

Assert.Equal(hostnames.Length, gateway.Resource.GeneratedHttpRoutes.Count);
Assert.All(gateway.Resource.GeneratedHttpRoutes, route => Assert.Single(route.Spec.Hostnames));
}

[Fact]
public async Task AddGateway_WithTls_GeneratesHttpsListener()
{
Expand Down
77 changes: 77 additions & 0 deletions tests/Aspire.Hosting.Kubernetes.Tests/KubernetesIngressTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,83 @@ public async Task AddIngress_WithIngressClassParameter_WithDefaultValue_Resolves
Assert.DoesNotContain("{{ .Values", content);
}

[Fact]
public async Task AddIngress_WithRuntimeOnlyHostnameParameter_DefersValue()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);

var hostname = builder.AddParameter("hostname", "localhost");
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public")
.WithHostname(hostname)
.WithTls();

var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();

ingress.WithPath("/api", api.GetEndpoint("http"));

using var app = builder.Build();
app.Run();

var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var valuesPath = Path.Combine(workspace.Path, "values.yaml");

await Verify(File.ReadAllText(ingressPath), "yaml")
.AppendContentAsFile(File.ReadAllText(valuesPath), "yaml");
}

[Fact]
public async Task AddIngress_WithHostname_AppliesToHostlessPath()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);

var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public")
.WithHostname("api.example.com")
.WithHostname("www.example.com");

var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();

ingress.WithPath("/api", api.GetEndpoint("http"));

using var app = builder.Build();
app.Run();

var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");

await Verify(File.ReadAllText(ingressPath), "yaml");
}

[Fact]
public async Task AddIngress_HostnameWithDefaultBackendWithoutTls_DoesNotGenerateHostRule()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);

var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public")
.WithHostname("api.example.com");

var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();

ingress.WithDefaultBackend(api.GetEndpoint("http"));

using var app = builder.Build();
app.Run();

var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");

await Verify(File.ReadAllText(ingressPath), "yaml");
}

[Fact]
public async Task AddIngress_WithTls_GeneratesTlsSection()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
apiVersion: "gateway.networking.k8s.io/v1"
kind: "HTTPRoute"
metadata:
name: "public-route"
spec:
parentRefs:
- name: "public"
hostnames:
- "api.example.com"
- "www.example.com"
rules:
- matches:
- path:
type: "PathPrefix"
value: "/api"
backendRefs:
- name: "myapi-service"
port: 8080
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
apiVersion: "gateway.networking.k8s.io/v1"
kind: "Gateway"
metadata:
name: "public"
spec:
gatewayClassName: "nginx"
listeners:
- name: "http"
protocol: "HTTP"
port: 80
allowedRoutes:
namespaces:
from: "Same"
- name: "https"
protocol: "HTTPS"
port: 443
hostname: "{{ .Values.parameters.public.hostname }}"
tls:
mode: "Terminate"
certificateRefs:
- name: "public-tls"
allowedRoutes:
namespaces:
from: "Same"
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
apiVersion: "gateway.networking.k8s.io/v1"
kind: "HTTPRoute"
metadata:
name: "public-route"
spec:
parentRefs:
- name: "public"
hostnames:
- "{{ .Values.parameters.public.hostname }}"
rules:
- matches:
- path:
type: "PathPrefix"
value: "/api"
backendRefs:
- name: "myapi-service"
port: 8080
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
parameters:
public:
hostname: ""
secrets: {}
config: {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
apiVersion: "networking.k8s.io/v1"
kind: "Ingress"
metadata:
name: "public"
spec:
defaultBackend:
service:
name: "myapi-service"
port:
name: "http"
Loading
Loading