File: KubernetesGatewayTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Kubernetes.Tests\Aspire.Hosting.Kubernetes.Tests.csproj (Aspire.Hosting.Kubernetes.Tests)
// 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);
    }
}