Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Helpers that validate publish-time intent for endpoints that are being
/// routed by ingress / gateway-style resources.
/// </summary>
/// <remarks>
/// 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 <see cref="EndpointReference"/> that the resource owner never
/// flagged as external. The check is performed during publish (when the
/// Helm chart is materialized) rather than at <c>WithRoute</c> call time
/// because authoring order is not significant: a user may legitimately
/// register the route before calling
/// <see cref="ResourceBuilderExtensions.WithExternalHttpEndpoints{T}"/> or
/// setting <see cref="EndpointAnnotation.IsExternal"/> directly.
/// </remarks>
internal static class EndpointRoutingValidation
{
/// <summary>
/// Throws an <see cref="InvalidOperationException"/> when the supplied
/// endpoint is not marked external on its <see cref="EndpointAnnotation"/>.
/// </summary>
/// <param name="endpoint">The routed endpoint reference.</param>
/// <param name="routingResourceKind">The kind of routing resource (e.g., <c>"Ingress"</c> or <c>"Gateway"</c>).</param>
/// <param name="routingResourceName">The name of the routing resource that owns the route.</param>
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.");
}
}
21 changes: 21 additions & 0 deletions src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// End-to-end coverage for the Kubernetes ingress/gateway validation that
/// requires routed endpoints to be marked external (see
/// <c>EndpointRoutingValidation.ThrowIfEndpointNotExternal</c>). The CLI
/// surface check matters because the validation throws during model
/// materialization on the <c>aspire publish</c> path and we want a regression
/// guard that exercises the full publish pipeline, not just the unit-level
/// helper.
/// </summary>
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
Comment thread
mitchdenny marked this conversation as resolved.
Outdated
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);
Comment thread
mitchdenny marked this conversation as resolved.

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.
Comment thread
mitchdenny marked this conversation as resolved.
Outdated
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;
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand Down Expand Up @@ -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);
}
Comment thread
mitchdenny marked this conversation as resolved.

[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<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 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<AggregateException>(app.Run);
var ex = aggregate.Flatten().InnerExceptions.OfType<InvalidOperationException>().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);
}
}
Loading
Loading