From 5f5c7488433a22464d83d84dbdf5371f97e8bdd7 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 19 May 2026 11:39:21 +1000 Subject: [PATCH 1/6] Require external endpoint for Kubernetes ingress/gateway routes Throw InvalidOperationException at publish time when an ingress or gateway route references an endpoint that was not flagged as external (via WithExternalHttpEndpoints or isExternal: true). Validation runs inside KubernetesEnvironmentResource.Process{Ingress,Gateway}Resources so it covers both the standard Kubernetes hosting and the AKS wrappers, and mirrors the existing Front Door check. Updates existing unit and deployment E2E tests whose routed endpoints were not previously marked external, and adds focused unit tests + a CLI E2E test that asserts aspire publish fails with the guidance message pointing at WithExternalHttpEndpoints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Extensions/EndpointRoutingValidation.cs | 57 +++++++ .../KubernetesEnvironmentResource.cs | 21 +++ ...tesPublishRequiresExternalEndpointTests.cs | 159 ++++++++++++++++++ ...esEnvironmentCertManagerDeploymentTests.cs | 3 + ...rnetesEnvironmentGatewayDeploymentTests.cs | 3 + .../KubernetesGatewayTlsDeploymentTests.cs | 4 + .../AzureKubernetesIngressTests.cs | 48 +++++- .../KubernetesGatewayTests.cs | 97 ++++++++++- .../KubernetesIngressTests.cs | 109 ++++++++++-- 9 files changed, 478 insertions(+), 23 deletions(-) create mode 100644 src/Aspire.Hosting.Kubernetes/Extensions/EndpointRoutingValidation.cs create mode 100644 tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs diff --git a/src/Aspire.Hosting.Kubernetes/Extensions/EndpointRoutingValidation.cs b/src/Aspire.Hosting.Kubernetes/Extensions/EndpointRoutingValidation.cs new file mode 100644 index 00000000000..71ef9f6b89e --- /dev/null +++ b/src/Aspire.Hosting.Kubernetes/Extensions/EndpointRoutingValidation.cs @@ -0,0 +1,57 @@ +// 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.ApplicationModel; + +namespace Aspire.Hosting.Kubernetes.Extensions; + +/// +/// Helpers that validate publish-time intent for endpoints that are being +/// routed by ingress / gateway-style resources. +/// +/// +/// Ingress and Gateway resources expose their backing service to traffic that +/// originates outside the cluster, so it is a privacy/security footgun to +/// route an that the resource owner never +/// flagged as external. The check is performed during publish (when the +/// Helm chart is materialized) rather than at WithRoute call time +/// because authoring order is not significant: a user may legitimately +/// register the route before calling +/// or +/// setting directly. +/// +internal static class EndpointRoutingValidation +{ + /// + /// Throws an when the supplied + /// endpoint is not marked external on its . + /// + /// The routed endpoint reference. + /// The kind of routing resource (e.g., "Ingress" or "Gateway"). + /// The name of the routing resource that owns the route. + public static void ThrowIfEndpointNotExternal( + EndpointReference endpoint, + string routingResourceKind, + string routingResourceName) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentException.ThrowIfNullOrEmpty(routingResourceKind); + ArgumentException.ThrowIfNullOrEmpty(routingResourceName); + + // EndpointAnnotation captures publish-time intent — the endpoint + // is external if and only if the author explicitly opted in, either + // through .WithExternalHttpEndpoints() or by passing isExternal: true + // when creating the endpoint annotation. + if (endpoint.EndpointAnnotation.IsExternal) + { + return; + } + + throw new InvalidOperationException( + $"Resource '{endpoint.Resource.Name}' endpoint '{endpoint.EndpointName}' is not marked as external " + + $"but is being routed by {routingResourceKind} '{routingResourceName}'. " + + $"Call .WithExternalHttpEndpoints() on the target resource or pass isExternal: true when " + + $"creating the endpoint annotation. {routingResourceKind} routes may only expose endpoints " + + $"that are explicitly marked external."); + } +} diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs index 96b61f95293..a7652c073c4 100644 --- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs +++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs @@ -546,6 +546,20 @@ private async Task ProcessIngressResources(DistributedApplicationModel model, Di continue; } + // Validate at publish time that every routed endpoint is flagged + // external. We do this before BuildIngressObject runs so the user + // sees the validation error before any partial work or warnings + // about missing deployment targets are emitted. + foreach (var route in ingressResource.Routes) + { + EndpointRoutingValidation.ThrowIfEndpointNotExternal(route.Endpoint, "Ingress", ingressResource.Name); + } + + if (ingressResource.DefaultBackend is { } defaultBackend) + { + EndpointRoutingValidation.ThrowIfEndpointNotExternal(defaultBackend.Endpoint, "Ingress", ingressResource.Name); + } + var ingress = await BuildIngressObject(ingressResource, deploymentTargets, logger, cancellationToken).ConfigureAwait(false); if (ingress is not null) { @@ -743,6 +757,13 @@ private async Task ProcessGatewayResources(DistributedApplicationModel model, Di continue; } + // Validate that every routed endpoint is flagged external before + // we materialize the Gateway and HTTPRoute objects. + foreach (var route in gatewayResource.Routes) + { + EndpointRoutingValidation.ThrowIfEndpointNotExternal(route.Endpoint, "Gateway", gatewayResource.Name); + } + await BuildGatewayObjects(gatewayResource, deploymentTargets, logger, cancellationToken).ConfigureAwait(false); } } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs new file mode 100644 index 00000000000..a7511c2226d --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs @@ -0,0 +1,159 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Cli.EndToEnd.Tests.Helpers; +using Aspire.Cli.Tests.Utils; +using Hex1b.Automation; +using Xunit; + +namespace Aspire.Cli.EndToEnd.Tests; + +/// +/// End-to-end coverage for the Kubernetes ingress/gateway validation that +/// requires routed endpoints to be marked external (see +/// EndpointRoutingValidation.ThrowIfEndpointNotExternal). The CLI +/// surface check matters because the validation throws during model +/// materialization on the aspire publish path and we want a regression +/// guard that exercises the full publish pipeline, not just the unit-level +/// helper. +/// +public sealed class KubernetesPublishRequiresExternalEndpointTests(ITestOutputHelper output) +{ + private const string ProjectName = "AspireK8sExternalCheck"; + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task IngressWithoutExternalEndpoint_FailsPublishWithGuidance() + { + await RunPublishFailureScenarioAsync( + // Wire an ingress that routes a non-external HTTP endpoint. The + // publish-time validation in EndpointRoutingValidation should + // throw before any Helm output is generated. + appHostBodyExtension: """ + #pragma warning disable ASPIREHOSTINGAZURE001 + var kube = builder.AddKubernetesEnvironment("kube"); + var api = builder.AddContainer("api", "nginx").WithHttpEndpoint(targetPort: 80); + kube.AddIngress("public").WithRoute("/", api.GetEndpoint("http")); + #pragma warning restore ASPIREHOSTINGAZURE001 + """); + } + + [Fact] + [CaptureWorkspaceOnFailure] + public async Task GatewayWithoutExternalEndpoint_FailsPublishWithGuidance() + { + await RunPublishFailureScenarioAsync( + appHostBodyExtension: """ + #pragma warning disable ASPIREHOSTINGAZURE001 + var kube = builder.AddKubernetesEnvironment("kube"); + var api = builder.AddContainer("api", "nginx").WithHttpEndpoint(targetPort: 80); + kube.AddGateway("public").WithRoute("/", api.GetEndpoint("http")); + #pragma warning restore ASPIREHOSTINGAZURE001 + """); + } + + private async Task RunPublishFailureScenarioAsync(string appHostBodyExtension) + { + var repoRoot = CliE2ETestHelpers.GetRepoRoot(); + var strategy = CliInstallStrategy.Detect(output.WriteLine); + var workspace = TemporaryWorkspace.Create(output); + + using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); + + var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken); + + var counter = new SequenceCounter(); + var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500)); + var testBodyFailed = false; + + try + { + await auto.PrepareDockerEnvironmentAsync(counter, workspace); + await auto.InstallAspireCliAsync(strategy, counter); + + // EmptyAppHost gives us a minimal csproj-based AppHost we can mutate. + await auto.AspireNewAsync(ProjectName, counter, template: AspireTemplate.EmptyAppHost); + + // cd into the project so subsequent `aspire add` and `aspire publish` + // commands resolve the AppHost via repo-root discovery. + await auto.TypeAsync($"cd {ProjectName}"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // The Kubernetes hosting package is required to compile the AppHost code + // we're about to write. `aspire add` resolves the version against the + // same feed configuration the rest of the CLI uses (including PR builds). + await auto.TypeAsync("aspire add Aspire.Hosting.Kubernetes"); + await auto.EnterAsync(); + await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180)); + + // Patch AppHost.cs in-place. The EmptyAppHost template emits a single + // `builder.Build().Run();` line we replace; failing to find it should + // surface as a clear test failure rather than a silently no-op publish. + var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName); + var appHostDir = Path.Combine(projectDir, $"{ProjectName}.AppHost"); + var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); + var content = File.ReadAllText(appHostFilePath); + const string buildRunPattern = "builder.Build().Run();"; + Assert.Contains(buildRunPattern, content); + content = content.Replace(buildRunPattern, appHostBodyExtension + Environment.NewLine + buildRunPattern); + File.WriteAllText(appHostFilePath, content); + + // ASPIRE_PLAYGROUND interferes with `--non-interactive`. See + // KubernetesPublishTests for full context. + await auto.TypeAsync("unset ASPIRE_PLAYGROUND"); + await auto.EnterAsync(); + await auto.WaitForSuccessPromptAsync(counter); + + // Drive aspire publish. The validation throws an InvalidOperationException + // during model materialization, so publish should exit with a non-zero code + // and surface our guidance message verbatim in stderr/stdout. + await auto.TypeAsync("aspire publish -o helm-output --non-interactive"); + await auto.EnterAsync(); + + var expectedCounter = counter.Value; + // We don't pin to a specific exit code — the publish pipeline currently + // surfaces validation failures as exit 1, but treating any non-zero + // ERR:* prompt as the success condition keeps this test stable across + // future exit-code refactors. + var errorPromptSearcher = new CellPatternSearcher() + .FindPattern(expectedCounter.ToString(CultureInfo.InvariantCulture)) + .RightText(" ERR:"); + + await auto.WaitUntilAsync( + snapshot => errorPromptSearcher.Search(snapshot).Count > 0, + TimeSpan.FromMinutes(5), + description: "waiting for aspire publish to fail"); + counter.Increment(); + + // After the publish exits, scrape the screen for the guidance fragments. + // We use a generous WaitUntilTextAsync so any in-progress rendering + // settles before we assert. + await auto.WaitUntilTextAsync("WithExternalHttpEndpoints", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitUntilTextAsync("'api'", timeout: TimeSpan.FromSeconds(30)); + await auto.WaitUntilTextAsync("'public'", timeout: TimeSpan.FromSeconds(30)); + } + catch + { + testBodyFailed = true; + throw; + } + finally + { + try + { + await auto.TypeAsync("exit"); + await auto.EnterAsync(); + await pendingRun; + } + catch + { + if (!testBodyFailed) + { + throw; + } + } + } + } +} diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentCertManagerDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentCertManagerDeploymentTests.cs index 289797c5bf3..5e436b11197 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentCertManagerDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentCertManagerDeploymentTests.cs @@ -149,6 +149,9 @@ private async Task DeployApiWithCertManagerToAzureKubernetesEnvironmentCore(Canc // its FQDN, the tls-fqdn-discovery pipeline step patches it onto the listener and the // cm-issuer-apply step ensures the ClusterIssuer is present so cert-manager can complete // the HTTP-01 challenge against the AGC FQDN. +// The Gateway route validation requires the routed endpoint to be marked external. +apiService.WithExternalHttpEndpoints(); + aks.AddGateway("api-gw") .WithLoadBalancer(publicLb) .WithRoute("/", apiService.GetEndpoint("http")) diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentGatewayDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentGatewayDeploymentTests.cs index b51ca811fd2..b161ab32f63 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentGatewayDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentGatewayDeploymentTests.cs @@ -141,6 +141,9 @@ private async Task DeployApiWithGatewayToAzureKubernetesEnvironmentCore(Cancella // stamps the alb.networking.azure.io association annotations and defaults the // gatewayClassName to "azure-alb-external". Routing "/" (Prefix) so any path the // starter template's apiservice exposes (/, /weatherforecast) flows through. +// The Gateway route validation requires the routed endpoint to be marked external. +apiService.WithExternalHttpEndpoints(); + aks.AddGateway("api-gw") .WithLoadBalancer(publicLb) .WithRoute("/", apiService.GetEndpoint("http")); diff --git a/tests/Aspire.Deployment.EndToEnd.Tests/KubernetesGatewayTlsDeploymentTests.cs b/tests/Aspire.Deployment.EndToEnd.Tests/KubernetesGatewayTlsDeploymentTests.cs index 652c5f0f2aa..695b84ab3a9 100644 --- a/tests/Aspire.Deployment.EndToEnd.Tests/KubernetesGatewayTlsDeploymentTests.cs +++ b/tests/Aspire.Deployment.EndToEnd.Tests/KubernetesGatewayTlsDeploymentTests.cs @@ -291,6 +291,10 @@ await auto.TypeAsync( helm.WithChartVersion(builder.AddParameter("chartversion")); }); +// The Gateway route validation requires the routed endpoint to be marked +// external. The starter template's `webfrontend` does not opt in by default. +webfrontend.WithExternalHttpEndpoints(); + var gateway = k8s.AddGateway("ingress") .WithGatewayClass("azure-alb-external") .WithGatewayAnnotation("alb.networking.azure.io/alb-name", "alb-aspire") diff --git a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesIngressTests.cs b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesIngressTests.cs index ea9922f6a8e..c44aa5e3187 100644 --- a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesIngressTests.cs +++ b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesIngressTests.cs @@ -21,7 +21,8 @@ public async Task AksAddIngress_WithRoute_GeneratesIngressInHelmOutput() .WithIngressClass("nginx"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); ingress.WithRoute("/", api.GetEndpoint("http")); @@ -125,8 +126,49 @@ public async Task WithLoadBalancer_RespectsExplicitGatewayClass() Assert.NotNull(gateway.Resource.GatewayClassName); var resolvedClass = await gateway.Resource.GatewayClassName!.GetValueAsync(default); Assert.Equal("custom-class", resolvedClass); + } + + [Fact] + public void AksAddIngress_WithRoute_NonExternalEndpoint_ThrowsOnPublish() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + + var aks = builder.AddAzureKubernetesEnvironment("aks"); + var ingress = aks.AddIngress("public").WithIngressClass("nginx"); + + var api = builder.AddContainer("myapi", "nginx") + .WithHttpEndpoint(targetPort: 8080); + + ingress.WithRoute("/", api.GetEndpoint("http")); + + var app = builder.Build(); + var aggregate = Assert.Throws(app.Run); + var ex = aggregate.Flatten().InnerExceptions.OfType().First(e => e.Message.Contains("WithExternalHttpEndpoints")); + + Assert.Contains("myapi", ex.Message); + Assert.Contains("WithExternalHttpEndpoints", ex.Message); + } + + [Fact] + public void AksAddGateway_WithRoute_NonExternalEndpoint_ThrowsOnPublish() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + + var aks = builder.AddAzureKubernetesEnvironment("aks"); + var gateway = aks.AddGateway("public").WithGatewayClass("nginx"); + + var api = builder.AddContainer("myapi", "nginx") + .WithHttpEndpoint(targetPort: 8080); + + gateway.WithRoute("/", api.GetEndpoint("http")); + + var app = builder.Build(); + var aggregate = Assert.Throws(app.Run); + var ex = aggregate.Flatten().InnerExceptions.OfType().First(e => e.Message.Contains("WithExternalHttpEndpoints")); - Assert.True(gateway.Resource.GatewayAnnotations.ContainsKey("alb.networking.azure.io/alb-name")); - Assert.True(gateway.Resource.GatewayAnnotations.ContainsKey("alb.networking.azure.io/alb-namespace")); + Assert.Contains("myapi", ex.Message); + Assert.Contains("WithExternalHttpEndpoints", ex.Message); } } diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs index c21428d2872..9bbc46e186a 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs @@ -18,7 +18,8 @@ public async Task AddGateway_WithRoute_GeneratesGatewayAndHttpRoute() .WithGatewayClass("nginx"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); gateway.WithRoute("/api", api.GetEndpoint("http")); @@ -59,7 +60,8 @@ public async Task AddGateway_WithHostRoute_GeneratesHostnameInHttpRoute() var gateway = k8s.AddGateway("public").WithGatewayClass("test"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); gateway.WithRoute("api.example.com", "/", api.GetEndpoint("http")); @@ -85,7 +87,8 @@ public async Task AddGateway_WithTls_GeneratesHttpsListener() var gateway = k8s.AddGateway("public").WithGatewayClass("test"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); gateway .WithRoute("api.example.com", "/", api.GetEndpoint("http")) @@ -116,7 +119,8 @@ public async Task AddGateway_WithTls_DoesNotDuplicateRoutes() var gateway = k8s.AddGateway("public").WithGatewayClass("test"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); gateway .WithRoute("api.example.com", "/", api.GetEndpoint("http")) @@ -141,10 +145,12 @@ public async Task AddGateway_MultipleRoutes_GroupsByHost() var gateway = k8s.AddGateway("public").WithGatewayClass("test"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); var web = builder.AddContainer("myweb", "nginx") - .WithHttpEndpoint(targetPort: 80); + .WithHttpEndpoint(targetPort: 80) + .WithExternalHttpEndpoints(); // Two routes on the same host ΓåÆ should be grouped into one HTTPRoute gateway.WithRoute("example.com", "/api", api.GetEndpoint("http")); @@ -238,7 +244,8 @@ public async Task AddGateway_WithTls_NoHostname_GeneratesHttpsListenerWithoutHos var gateway = k8s.AddGateway("public").WithGatewayClass("azure-alb-external"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); // WithTls() without WithHostname() — should still generate an HTTPS listener gateway @@ -284,7 +291,8 @@ public async Task AddGateway_WithTls_BeforeWithHostname_HostnameStillAppliedToHt var gateway = k8s.AddGateway("public").WithGatewayClass("azure-alb-external"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); gateway .WithRoute("/", api.GetEndpoint("http")) @@ -309,4 +317,77 @@ public async Task AddGateway_WithTls_BeforeWithHostname_HostnameStillAppliedToHt 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 tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.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(app.Run); + var ex = aggregate.Flatten().InnerExceptions.OfType().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 tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.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(app.Run); + var ex = aggregate.Flatten().InnerExceptions.OfType().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 tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.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(tempDir.Path, "templates", "public", "public.yaml"); + Assert.True(File.Exists(gatewayFile)); + var content = await File.ReadAllTextAsync(gatewayFile); + Assert.Contains("Gateway", content); + } } diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesIngressTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesIngressTests.cs index 9fb7f9ac509..6727df13cd8 100644 --- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesIngressTests.cs +++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesIngressTests.cs @@ -18,7 +18,8 @@ public async Task AddIngress_WithRoute_GeneratesIngressYaml() .WithIngressClass("nginx"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); ingress.WithRoute("/api", api.GetEndpoint("http")); @@ -48,7 +49,8 @@ public async Task AddIngress_WithHostRoute_GeneratesHostRule() var ingress = k8s.AddIngress("public"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); ingress.WithRoute("api.example.com", "/", api.GetEndpoint("http")); @@ -73,7 +75,8 @@ public async Task AddIngress_WithTls_GeneratesTlsSection() var ingress = k8s.AddIngress("public"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); ingress .WithRoute("api.example.com", "/", api.GetEndpoint("http")) @@ -100,7 +103,8 @@ public async Task AddIngress_WithTls_BeforeWithHostname_HostnameIncludedInTlsHos var ingress = k8s.AddIngress("public"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); ingress .WithRoute("api.example.com", "/", api.GetEndpoint("http")) @@ -129,10 +133,12 @@ public async Task AddIngress_WithMultipleRoutes_GroupsByHost() var ingress = k8s.AddIngress("public"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); var web = builder.AddContainer("myweb", "nginx") - .WithHttpEndpoint(targetPort: 80); + .WithHttpEndpoint(targetPort: 80) + .WithExternalHttpEndpoints(); // Two routes on the same host ingress.WithRoute("example.com", "/api", api.GetEndpoint("http")); @@ -161,7 +167,8 @@ public async Task AddIngress_WithAnnotations_GeneratesAnnotations() .WithIngressAnnotation("nginx.ingress.kubernetes.io/rewrite-target", "/$1"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); ingress.WithRoute("/", api.GetEndpoint("http")); @@ -184,7 +191,8 @@ public async Task AddIngress_WithExactPathType_GeneratesExactPathType() var ingress = k8s.AddIngress("public"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); ingress.WithRoute("/exact", api.GetEndpoint("http"), IngressPathType.Exact); @@ -255,10 +263,12 @@ public async Task AddIngress_MultipleIngresses_GeneratesSeparateYaml() .WithIngressClass("internal"); var api = builder.AddContainer("myapi", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); var admin = builder.AddContainer("myadmin", "nginx") - .WithHttpEndpoint(targetPort: 9090); + .WithHttpEndpoint(targetPort: 9090) + .WithExternalHttpEndpoints(); publicIngress.WithRoute("/", api.GetEndpoint("http")); internalIngress.WithRoute("/admin", admin.GetEndpoint("http")); @@ -290,7 +300,8 @@ public async Task AddIngress_TlsWithDefaultBackend_AutoGeneratesHostRule() var ingress = k8s.AddIngress("public"); var web = builder.AddContainer("myweb", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); // TLS host + default backend but NO explicit route for the TLS host. // The ingress should auto-generate a rule for the TLS host. @@ -324,7 +335,8 @@ public async Task AddIngress_TlsWithExplicitRoute_DoesNotDuplicate() var ingress = k8s.AddIngress("public"); var web = builder.AddContainer("myweb", "nginx") - .WithHttpEndpoint(targetPort: 8080); + .WithHttpEndpoint(targetPort: 8080) + .WithExternalHttpEndpoints(); // Explicit route for the TLS host ΓÇö should NOT auto-generate another one ingress @@ -381,4 +393,77 @@ public void AddIngress_ResourceType_HasCorrectParent() Assert.Equal(k8s.Resource, ingress.Resource.Parent); Assert.IsType(ingress.Resource); } + + [Fact] + public void AddIngress_WithRoute_NonExternalEndpoint_ThrowsOnPublish() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + + var k8s = builder.AddKubernetesEnvironment("env"); + var ingress = k8s.AddIngress("public"); + + // Intentionally omit WithExternalHttpEndpoints to ensure the publish-time + // validation fires and surfaces a clear, actionable message. + var api = builder.AddContainer("myapi", "nginx") + .WithHttpEndpoint(targetPort: 8080); + + ingress.WithRoute("/", api.GetEndpoint("http")); + + var app = builder.Build(); + var aggregate = Assert.Throws(app.Run); + var ex = aggregate.Flatten().InnerExceptions.OfType().First(e => e.Message.Contains("WithExternalHttpEndpoints")); + + Assert.Contains("myapi", ex.Message); + Assert.Contains("public", ex.Message); + Assert.Contains("WithExternalHttpEndpoints", ex.Message); + } + + [Fact] + public void AddIngress_WithDefaultBackend_NonExternalEndpoint_ThrowsOnPublish() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + + var k8s = builder.AddKubernetesEnvironment("env"); + var ingress = k8s.AddIngress("public"); + + var web = builder.AddContainer("myweb", "nginx") + .WithHttpEndpoint(targetPort: 8080); + + ingress.WithDefaultBackend(web.GetEndpoint("http")); + + var app = builder.Build(); + var aggregate = Assert.Throws(app.Run); + var ex = aggregate.Flatten().InnerExceptions.OfType().First(e => e.Message.Contains("WithExternalHttpEndpoints")); + + Assert.Contains("myweb", ex.Message); + Assert.Contains("public", ex.Message); + Assert.Contains("WithExternalHttpEndpoints", ex.Message); + } + + [Fact] + public async Task AddIngress_WithRoute_ExternalEndpoint_Succeeds() + { + using var tempDir = new TestTempDirectory(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, tempDir.Path); + + var k8s = builder.AddKubernetesEnvironment("env"); + var ingress = k8s.AddIngress("public"); + + // WithExternalHttpEndpoints applied AFTER WithRoute to demonstrate that + // authoring order does not matter: validation runs at publish time. + var api = builder.AddContainer("myapi", "nginx") + .WithHttpEndpoint(targetPort: 8080); + + ingress.WithRoute("/", api.GetEndpoint("http")); + + api.WithExternalHttpEndpoints(); + + var app = builder.Build(); + app.Run(); + + var ingressPath = Path.Combine(tempDir.Path, "templates", "public", "public.yaml"); + Assert.True(File.Exists(ingressPath)); + } } From 4795698f2a0f5a87930024444f903878b1fd81c0 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 19 May 2026 12:19:46 +1000 Subject: [PATCH 2/6] Address PR review feedback - Restore the two AGC annotation assertions in WithLoadBalancer_RespectsExplicitGatewayClass that were accidentally dropped when reformatting the test for the new validation cases. - Remove the misleading #pragma warning disable ASPIREHOSTINGAZURE001 blocks from the new CLI E2E test. None of the K8s extension APIs the embedded AppHost calls carries that diagnostic, so the pragma was silently ignored and only added noise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KubernetesPublishRequiresExternalEndpointTests.cs | 4 ---- .../AzureKubernetesIngressTests.cs | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs index a7511c2226d..657bd94af8c 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs @@ -31,11 +31,9 @@ await RunPublishFailureScenarioAsync( // publish-time validation in EndpointRoutingValidation should // throw before any Helm output is generated. appHostBodyExtension: """ - #pragma warning disable ASPIREHOSTINGAZURE001 var kube = builder.AddKubernetesEnvironment("kube"); var api = builder.AddContainer("api", "nginx").WithHttpEndpoint(targetPort: 80); kube.AddIngress("public").WithRoute("/", api.GetEndpoint("http")); - #pragma warning restore ASPIREHOSTINGAZURE001 """); } @@ -45,11 +43,9 @@ public async Task GatewayWithoutExternalEndpoint_FailsPublishWithGuidance() { await RunPublishFailureScenarioAsync( appHostBodyExtension: """ - #pragma warning disable ASPIREHOSTINGAZURE001 var kube = builder.AddKubernetesEnvironment("kube"); var api = builder.AddContainer("api", "nginx").WithHttpEndpoint(targetPort: 80); kube.AddGateway("public").WithRoute("/", api.GetEndpoint("http")); - #pragma warning restore ASPIREHOSTINGAZURE001 """); } diff --git a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesIngressTests.cs b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesIngressTests.cs index c44aa5e3187..cfa2968946b 100644 --- a/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesIngressTests.cs +++ b/tests/Aspire.Hosting.Azure.Kubernetes.Tests/AzureKubernetesIngressTests.cs @@ -126,6 +126,9 @@ public async Task WithLoadBalancer_RespectsExplicitGatewayClass() Assert.NotNull(gateway.Resource.GatewayClassName); var resolvedClass = await gateway.Resource.GatewayClassName!.GetValueAsync(default); Assert.Equal("custom-class", resolvedClass); + + Assert.True(gateway.Resource.GatewayAnnotations.ContainsKey("alb.networking.azure.io/alb-name")); + Assert.True(gateway.Resource.GatewayAnnotations.ContainsKey("alb.networking.azure.io/alb-namespace")); } [Fact] From a56bb569b19792b92b89a61a4ee798412ba96e1d Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 19 May 2026 12:51:09 +1000 Subject: [PATCH 3/6] Fix CLI E2E test to use Starter template for AppHost layout The EmptyAppHost template lays out the project as `{ProjectName}/apphost.cs` (file-based app), not `{ProjectName}/{ProjectName}.AppHost/AppHost.cs`, which caused the test to fail with DirectoryNotFoundException when reading the AppHost to mutate it. Switch to the Starter template so the mutation path matches KubernetesPublishTests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KubernetesPublishRequiresExternalEndpointTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs index 657bd94af8c..605cb766c59 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs @@ -68,8 +68,11 @@ private async Task RunPublishFailureScenarioAsync(string appHostBodyExtension) await auto.PrepareDockerEnvironmentAsync(counter, workspace); await auto.InstallAspireCliAsync(strategy, counter); - // EmptyAppHost gives us a minimal csproj-based AppHost we can mutate. - await auto.AspireNewAsync(ProjectName, counter, template: AspireTemplate.EmptyAppHost); + // The starter template gives us the conventional + // `{ProjectName}/{ProjectName}.AppHost/AppHost.cs` layout, matching + // KubernetesPublishTests so the AppHost-mutation logic below stays + // consistent across both tests. + await auto.AspireNewAsync(ProjectName, counter, useRedisCache: false); // cd into the project so subsequent `aspire add` and `aspire publish` // commands resolve the AppHost via repo-root discovery. From 6048d7cedf6ccbb8cf8e71b41e4ef25d47e15a34 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 19 May 2026 14:01:47 +1000 Subject: [PATCH 4/6] Update stale EmptyAppHost comment to reference Starter template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KubernetesPublishRequiresExternalEndpointTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs index 605cb766c59..bff1b3ac4bd 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs @@ -87,9 +87,10 @@ private async Task RunPublishFailureScenarioAsync(string appHostBodyExtension) await auto.EnterAsync(); await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180)); - // Patch AppHost.cs in-place. The EmptyAppHost template emits a single - // `builder.Build().Run();` line we replace; failing to find it should - // surface as a clear test failure rather than a silently no-op publish. + // Patch AppHost.cs in-place. The Starter template's AppHost.cs ends + // with `builder.Build().Run();`; we insert the K8s wiring immediately + // before it. Failing to find the marker should surface as a clear + // test failure rather than a silently no-op publish. var projectDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName); var appHostDir = Path.Combine(projectDir, $"{ProjectName}.AppHost"); var appHostFilePath = Path.Combine(appHostDir, "AppHost.cs"); From 1cde3b2398b3d880639b2c930d87556b4ce66df1 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 19 May 2026 14:33:35 +1000 Subject: [PATCH 5/6] Use 'using' for TemporaryWorkspace in K8s publish E2E test Match the established pattern in the sibling KubernetesPublishTests so the temp directory is cleaned up after the test runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../KubernetesPublishRequiresExternalEndpointTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs index bff1b3ac4bd..c311e9e5cfc 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/KubernetesPublishRequiresExternalEndpointTests.cs @@ -53,7 +53,7 @@ private async Task RunPublishFailureScenarioAsync(string appHostBodyExtension) { var repoRoot = CliE2ETestHelpers.GetRepoRoot(); var strategy = CliInstallStrategy.Detect(output.WriteLine); - var workspace = TemporaryWorkspace.Create(output); + using var workspace = TemporaryWorkspace.Create(output); using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace); From 5d5857003572c81038019824ae8dac0ba63ecaf8 Mon Sep 17 00:00:00 2001 From: Mitch Denny Date: Tue, 19 May 2026 14:35:23 +1000 Subject: [PATCH 6/6] Document external-endpoint requirement in deployment skill Update the aspire-deployment skill's Kubernetes reference to note that endpoints routed via AddIngress/AddGateway must be explicitly marked external, and that publish fails fast otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/aspire-deployment/references/kubernetes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/skills/aspire-deployment/references/kubernetes.md b/.agents/skills/aspire-deployment/references/kubernetes.md index bfcdc0b6e50..aa20de18ab0 100644 --- a/.agents/skills/aspire-deployment/references/kubernetes.md +++ b/.agents/skills/aspire-deployment/references/kubernetes.md @@ -85,6 +85,7 @@ Make these changes in the AppHost: 5. For TypeScript AppHosts, verify the current language-specific docs before assuming an equivalent assignment API. 6. Use `k8s.WithHelm(...)` / `k8s.withHelm(...)` only when the user needs chart name, release name, namespace, chart version, or other Helm settings. 7. Use `k8s.AddGateway(...)`, `k8s.AddIngress(...)`, or the TypeScript equivalents when public exposure is required. Otherwise services remain internal by default. For a simple public web frontend on a cloud Kubernetes provider, a per-resource `LoadBalancer` Service can be the direct exposure model; keep backend/internal services as `ClusterIP`. + - **Routed endpoints must be marked external.** Any endpoint routed by an Ingress or Gateway (via `WithRoute(...)` or `WithDefaultBackend(...)`) must come from a resource that opts in with `.WithExternalHttpEndpoints()` (C#) or `isExternal: true` on the endpoint annotation. `aspire publish` fails fast with an `InvalidOperationException` from `EndpointRoutingValidation` if a non-external endpoint is routed, so always pair `AddIngress`/`AddGateway` plumbing with explicit external opt-in on the target resource. This applies to AKS through `AzureKubernetesEnvironment` as well. 8. Use `PublishAsKubernetesService(...)` / `publishAsKubernetesService(...)` only for per-resource Kubernetes manifest customization. ## Azure Kubernetes Service (AKS) setup