// 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.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using static Aspire.Hosting.Kubernetes.Tests.PipelineStepTestHelpers;
namespace Aspire.Hosting.Kubernetes.Tests;
public class KubernetesGatewayTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task AddGateway_WithRoute_GeneratesGatewayAndHttpRoute()
{
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");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
gateway.WithRoute("/api", api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
// Should generate Gateway and HTTPRoute files
var gatewayDir = Path.Combine(workspace.Path, "templates", "public");
Assert.True(Directory.Exists(gatewayDir), $"Gateway templates dir not found at {gatewayDir}");
var files = Directory.GetFiles(gatewayDir);
Assert.True(files.Length >= 2, $"Expected at least 2 files (Gateway + HTTPRoute), got {files.Length}");
// Check Gateway YAML
var gatewayFile = files.FirstOrDefault(f => Path.GetFileName(f) == "public.yaml");
Assert.NotNull(gatewayFile);
var gatewayContent = await File.ReadAllTextAsync(gatewayFile);
Assert.Contains("Gateway", gatewayContent);
Assert.Contains("nginx", gatewayContent);
Assert.Contains("HTTP", gatewayContent);
// Check HTTPRoute YAML
var routeFile = files.FirstOrDefault(f => f.Contains("route"));
Assert.NotNull(routeFile);
var routeContent = await File.ReadAllTextAsync(routeFile);
Assert.Contains("HTTPRoute", routeContent);
Assert.Contains("/api", routeContent);
Assert.Contains("PathPrefix", routeContent);
}
[Fact]
public async Task AddGateway_WithHostRoute_GeneratesHostnameInHttpRoute()
{
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 api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
gateway.WithRoute("api.example.com", "/", api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
var gatewayDir = Path.Combine(workspace.Path, "templates", "public");
var routeFile = Directory.GetFiles(gatewayDir).FirstOrDefault(f => f.Contains("route"));
Assert.NotNull(routeFile);
var content = await File.ReadAllTextAsync(routeFile);
Assert.Contains("api.example.com", content);
Assert.Contains("HTTPRoute", content);
}
[Fact]
public async Task AddGateway_WithTls_GeneratesHttpsListener()
{
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 api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
gateway
.WithRoute("api.example.com", "/", api.GetEndpoint("http"))
.WithHostname("api.example.com").WithTls("my-tls-secret");
var app = builder.Build();
app.Run();
// Check Gateway has HTTPS listener
var gatewayFile = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var content = await File.ReadAllTextAsync(gatewayFile);
Assert.Contains("HTTPS", content);
Assert.Contains("Terminate", content);
Assert.Contains("my-tls-secret", content);
Assert.Contains("api.example.com", content);
// Should also have HTTP listener
Assert.Contains("HTTP", content);
var steps = await CreateStepsAsync(app.Services, k8s.Resource);
Assert.Equal(["gateway-field-cleanup-env", "tls-bootstrap-env"], GatewayOrTlsStepNames(steps));
}
[Fact]
public async Task AddGateway_WithTls_DoesNotDuplicateRoutes()
{
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 api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
gateway
.WithRoute("api.example.com", "/", api.GetEndpoint("http"))
.WithHostname("api.example.com").WithTls("my-tls-secret");
var app = builder.Build();
app.Run();
// Should have exactly 1 HTTPRoute file (TLS doesn't create a separate route)
var gatewayDir = Path.Combine(workspace.Path, "templates", "public");
var routeFiles = Directory.GetFiles(gatewayDir).Where(f => f.Contains("route")).ToArray();
Assert.Single(routeFiles);
}
[Fact]
public async Task AddGateway_MultipleRoutes_GroupsByHost()
{
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 api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
var web = builder.AddContainer("myweb", "nginx")
.WithHttpEndpoint(targetPort: 80)
.WithExternalHttpEndpoints();
// Two routes on the same host → should be grouped into one HTTPRoute
gateway.WithRoute("example.com", "/api", api.GetEndpoint("http"));
gateway.WithRoute("example.com", "/", web.GetEndpoint("http"));
// One route on a different host
gateway.WithRoute("other.com", "/", api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
var gatewayDir = Path.Combine(workspace.Path, "templates", "public");
var routeFiles = Directory.GetFiles(gatewayDir).Where(f => f.Contains("route")).ToArray();
// Should have 2 HTTPRoute files: one for example.com, one for other.com
Assert.Equal(2, routeFiles.Length);
}
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task AddGateway_NoRoutes_DoesNotGenerateYamlOrTlsSteps(bool hasTls, bool hasHostname)
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var gateway = k8s.AddGateway("empty");
if (hasTls)
{
gateway.WithTls("my-tls-secret");
}
if (hasHostname)
{
gateway.WithHostname("api.example.com");
}
builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
using var app = builder.Build();
app.Run();
var gatewayDir = Path.Combine(workspace.Path, "templates", "empty");
Assert.False(Directory.Exists(gatewayDir), $"Gateway directory should not exist at {gatewayDir}");
// Assert on the whole filtered set rather than probing known step names one by one, so a
// future gateway/TLS step added without the route-eligibility filter also fails here.
var steps = await CreateStepsAsync(app.Services, k8s.Resource);
Assert.Empty(GatewayOrTlsStepNames(steps));
}
[Fact]
public async Task AddGateway_NoRoutes_WarnsThatGatewayAndTlsAreSkipped()
{
// The warning is the only signal a user gets that their Gateway (and its certificate) was
// silently dropped, so assert its content rather than just the absence of artifacts.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var testSink = new TestSink();
builder.Services.AddLogging(logging => logging.AddProvider(new TestLoggerProvider(testSink)));
var k8s = builder.AddKubernetesEnvironment("env");
k8s.AddGateway("empty").WithTls("my-tls-secret");
builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
using var app = builder.Build();
app.Run();
var warning = Assert.Single(
testSink.Writes,
w => w.LogLevel == LogLevel.Warning && w.Message is not null && w.Message.Contains("empty", StringComparison.Ordinal));
Assert.Equal(
"Gateway 'empty' has no routes configured. The Gateway, routes, TLS certificate, and load-balancer frontend will not be created.",
warning.Message);
}
[Fact]
public void WithRoute_InvalidPath_Throws()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var k8s = builder.AddKubernetesEnvironment("env");
var gateway = k8s.AddGateway("public").WithGatewayClass("test");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
Assert.Throws<ArgumentException>(() =>
gateway.WithRoute("no-leading-slash", api.GetEndpoint("http")));
}
[Fact]
public void AddGateway_HasCorrectParent()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var k8s = builder.AddKubernetesEnvironment("env");
var gateway = k8s.AddGateway("public").WithGatewayClass("test");
Assert.Equal(k8s.Resource, gateway.Resource.Parent);
Assert.IsType<KubernetesGatewayResource>(gateway.Resource);
}
[Fact]
public async Task AddGateway_BackwardCompatible_NoGatewayNoChange()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
builder.AddKubernetesEnvironment("env");
builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
var app = builder.Build();
app.Run();
// Service and deployment should exist but no gateway
var templatesDir = Path.Combine(workspace.Path, "templates", "myapi");
Assert.True(Directory.Exists(templatesDir));
var files = Directory.GetFiles(templatesDir);
Assert.DoesNotContain(files, f => f.Contains("gateway", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(files, f => f.Contains("route", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task AddGateway_WithTls_NoHostname_GeneratesHttpsListenerWithoutHostname()
{
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("azure-alb-external");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
// WithTls() without WithHostname() — should still generate an HTTPS listener
gateway
.WithRoute("/", api.GetEndpoint("http"))
.WithTls("my-tls-secret");
var app = builder.Build();
app.Run();
// Check Gateway has HTTPS listener without a hostname
var gatewayFile = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var content = await File.ReadAllTextAsync(gatewayFile);
Assert.Contains("HTTPS", content);
Assert.Contains("Terminate", content);
Assert.Contains("my-tls-secret", content);
// Should also have HTTP listener
Assert.Contains("HTTP", content);
// The HTTPS listener should NOT have a hostname field (since no WithHostname was called)
// Verify it has the listener but the hostname line should not appear after HTTPS
var lines = content.Split('\n').Select(l => l.Trim()).ToList();
var httpsIndex = lines.FindIndex(l => l.Contains("protocol:") && l.Contains("HTTPS"));
Assert.True(httpsIndex >= 0, "HTTPS listener not found in:\n" + content);
// Find the next listener or end of listeners to check there's no hostname
var nextListenerOrEnd = lines.FindIndex(httpsIndex + 1, l => l.StartsWith("- name:") || l == "");
var httpsSection = lines.Skip(httpsIndex).Take((nextListenerOrEnd > httpsIndex ? nextListenerOrEnd : lines.Count) - httpsIndex);
Assert.DoesNotContain(httpsSection, l => l.StartsWith("hostname:") || l.StartsWith("hostname "));
var steps = await CreateStepsAsync(app.Services, k8s.Resource);
Assert.Equal(["gateway-field-cleanup-env", "tls-fqdn-discovery-env"], GatewayOrTlsStepNames(steps));
}
[Fact]
public async Task AddGateway_WithTls_BeforeWithHostname_HostnameStillAppliedToHttpsListener()
{
// Regression test: WithTls() must not snapshot the hostname list at call time.
// The hostname is registered AFTER WithTls() here; the generated HTTPS listener
// must still pick it up, otherwise cert-manager will issue a cert for the wrong
// hostname (or fall back to the gateway's auto-assigned FQDN).
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("azure-alb-external");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
gateway
.WithRoute("/", api.GetEndpoint("http"))
.WithTls("my-tls-secret")
.WithHostname("api.example.com");
var app = builder.Build();
app.Run();
var gatewayFile = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var content = await File.ReadAllTextAsync(gatewayFile);
Assert.Contains("HTTPS", content);
Assert.Contains("my-tls-secret", content);
Assert.Contains("api.example.com", content);
var lines = content.Split('\n').Select(l => l.Trim()).ToList();
var httpsIndex = lines.FindIndex(l => l.Contains("protocol:") && l.Contains("HTTPS"));
Assert.True(httpsIndex >= 0, "HTTPS listener not found in:\n" + content);
var nextListenerOrEnd = lines.FindIndex(httpsIndex + 1, l => l.StartsWith("- name:") || l == "");
var httpsSection = lines.Skip(httpsIndex).Take((nextListenerOrEnd > httpsIndex ? nextListenerOrEnd : lines.Count) - httpsIndex).ToList();
Assert.Contains(httpsSection, l => l.Contains("hostname:") && l.Contains("api.example.com"));
}
[Fact]
public void AddGateway_WithRoute_NonExternalEndpoint_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");
// Intentionally omit WithExternalHttpEndpoints — the publish-time
// validation must surface a clear, actionable error.
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
gateway.WithRoute("/api", api.GetEndpoint("http"));
var app = builder.Build();
var aggregate = Assert.Throws<AggregateException>(app.Run);
var ex = aggregate.Flatten().InnerExceptions.OfType<InvalidOperationException>().First(e => e.Message.Contains("WithExternalHttpEndpoints"));
Assert.Contains("myapi", ex.Message);
Assert.Contains("public", ex.Message);
Assert.Contains("WithExternalHttpEndpoints", ex.Message);
}
[Fact]
public void AddGateway_WithHostRoute_NonExternalEndpoint_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");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
gateway.WithRoute("api.example.com", "/", api.GetEndpoint("http"));
var app = builder.Build();
var aggregate = Assert.Throws<AggregateException>(app.Run);
var ex = aggregate.Flatten().InnerExceptions.OfType<InvalidOperationException>().First(e => e.Message.Contains("WithExternalHttpEndpoints"));
Assert.Contains("myapi", ex.Message);
Assert.Contains("WithExternalHttpEndpoints", ex.Message);
}
[Fact]
public async Task AddGateway_WithRoute_ExternalEndpoint_Succeeds()
{
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");
// WithExternalHttpEndpoints applied AFTER WithRoute to prove that
// authoring order does not matter — validation runs at publish time.
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
gateway.WithRoute("/api", api.GetEndpoint("http"));
api.WithExternalHttpEndpoints();
var app = builder.Build();
app.Run();
var gatewayFile = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
Assert.True(File.Exists(gatewayFile));
var content = await File.ReadAllTextAsync(gatewayFile);
Assert.Contains("Gateway", content);
}
/// <summary>
/// The deployment-target step is reachable from two pipeline executions: it is RequiredBy
/// "before-start" and it is also part of the publish DAG. The step guards against adding a second
/// DeploymentTargetAnnotation, but gateway route generation runs downstream of that guard, so a
/// second pass appended every route again. The rendered chart hid it, because duplicate routes
/// share a name and overwrite each other's file — the list itself grew on every pass.
/// </summary>
[Fact]
public async Task AddGateway_WhenDeploymentTargetsArePreparedTwice_DoesNotDuplicateHttpRoutes()
{
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");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
gateway.WithRoute("/api", api.GetEndpoint("http"));
var app = builder.Build();
var steps = await CreateStepsAsync(app.Services, k8s.Resource);
var prepareStep = Assert.Single(steps, step => step.Name == "prepare-deployment-targets-env");
await RunStepAsync(app.Services, prepareStep);
await RunStepAsync(app.Services, prepareStep);
var gatewayResource = Assert.IsType<KubernetesGatewayResource>(gateway.Resource);
var route = Assert.Single(gatewayResource.GeneratedHttpRoutes);
var rule = Assert.Single(route.Spec.Rules);
var match = Assert.Single(rule.Matches);
Assert.Equal("/api", match.Path?.Value);
}
}