// 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;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
namespace Aspire.Hosting.Kubernetes.Tests;
public class KubernetesIngressTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task AddIngress_WithPath_GeneratesIngressYaml()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public")
.WithIngressClass("nginx");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
ingress.WithPath("/api", api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
// Verify ingress YAML was generated
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
Assert.True(File.Exists(ingressPath), $"Expected ingress YAML at {ingressPath}");
var content = await File.ReadAllTextAsync(ingressPath);
Assert.Contains("Ingress", content);
Assert.Contains("ingressClassName", content);
Assert.Contains("nginx", content);
Assert.Contains("path: \"/api\"", content);
Assert.Contains("pathType", content);
Assert.Contains("Prefix", content);
}
[Fact]
public async Task AddIngress_WithHostAndPath_GeneratesHostRule()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
ingress.WithPath("api.example.com", "/", api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
Assert.True(File.Exists(ingressPath), $"Expected ingress YAML at {ingressPath}");
var content = await File.ReadAllTextAsync(ingressPath);
Assert.Contains("api.example.com", content);
Assert.Contains("path: \"/\"", content);
}
[Fact]
public async Task AddIngress_WithIngressClassParameter_GeneratesHelmReferenceAndPlaceholder()
{
// Regression test: using a ParameterResource with the WithIngressClass overload
// previously rendered the literal format string ("{0}") into ingressClassName
// when the parameter had no value available at publish time. The fix substitutes a
// Helm template expression and captures the parameter so the deploy-time values
// override file (and chart values.yaml placeholder) include the entry.
using var workspace = TemporaryWorkspace.Create(outputHelper);
// Pipeline:ClearCache=true prevents loading of leaked deployment state from
// <ASPIRE_HOME>/deployments/<sha>/<env>.json (which would otherwise auto-resolve
// parameters from prior test runs and bypass the MissingParameterValueException path).
var builder = TestDistributedApplicationBuilder.Create(
"AppHost:Operation=publish",
$"Pipeline:OutputPath={workspace.Path}",
"Pipeline:LogLevel=information",
"Pipeline:Step=publish",
"Pipeline:ClearCache=true");
// Use a unique parameter name per test run to defeat any persistent state file lookup.
var parameterName = $"ingressclass{Guid.NewGuid():N}";
var ingressClass = builder.AddParameter(parameterName);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public")
.WithIngressClass(ingressClass);
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
ingress.WithPath("/api", api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
Assert.True(File.Exists(ingressPath), $"Expected ingress YAML at {ingressPath}");
var content = await File.ReadAllTextAsync(ingressPath);
Assert.DoesNotContain("\"{0}\"", content);
Assert.Contains($"{{{{ .Values.parameters.public.{parameterName} }}}}", content);
var valuesPath = Path.Combine(workspace.Path, "values.yaml");
Assert.True(File.Exists(valuesPath), $"Expected values.yaml at {valuesPath}");
var values = await File.ReadAllTextAsync(valuesPath);
// Expect a placeholder entry under parameters: public: <parameterName>: so consumers
// of the published Helm chart can fill it in (and `helm template` won't substitute <no value>).
Assert.Matches(
new System.Text.RegularExpressions.Regex(@"parameters:\s*[\r\n]+\s*public:\s*[\r\n]+\s*" + System.Text.RegularExpressions.Regex.Escape(parameterName) + @"\s*:"),
values);
}
[Fact]
public async Task AddIngress_WithIngressClassParameter_WithDefaultValue_ResolvesAtPublish()
{
// When a parameter has a publish-time default, the resolved value should be inlined
// into the manifest rather than rendered as a Helm template reference.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var ingressClass = builder.AddParameter("ingressclass", "nginx", publishValueAsDefault: true);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public")
.WithIngressClass(ingressClass);
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
ingress.WithPath("/api", api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var content = await File.ReadAllTextAsync(ingressPath);
Assert.Contains("ingressClassName: \"nginx\"", content);
Assert.DoesNotContain("{{ .Values", content);
}
[Fact]
public async Task AddIngress_WithTls_GeneratesTlsSection()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
ingress
.WithPath("api.example.com", "/", api.GetEndpoint("http"))
.WithHostname("api.example.com").WithTls("my-tls-secret");
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var content = await File.ReadAllTextAsync(ingressPath);
Assert.Contains("my-tls-secret", content);
Assert.Contains("api.example.com", content);
}
[Fact]
public async Task AddIngress_WithTls_BeforeWithHostname_HostnameIncludedInTlsHosts()
{
// Regression test: WithTls() must not snapshot the hostname list at call time.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
ingress
.WithPath("api.example.com", "/", api.GetEndpoint("http"))
.WithTls("my-tls-secret")
.WithHostname("api.example.com");
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var content = await File.ReadAllTextAsync(ingressPath);
Assert.Contains("my-tls-secret", content);
Assert.Contains("api.example.com", content);
// The TLS section should list the hostname under hosts.
Assert.Matches(@"tls:[\s\S]*?hosts:[\s\S]*?api\.example\.com", content);
}
[Fact]
public async Task AddIngress_WithMultiplePaths_GroupsByHost()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
var web = builder.AddContainer("myweb", "nginx")
.WithHttpEndpoint(targetPort: 80)
.WithExternalHttpEndpoints();
// Two paths on the same host
ingress.WithPath("example.com", "/api", api.GetEndpoint("http"));
ingress.WithPath("example.com", "/", web.GetEndpoint("http"));
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var content = await File.ReadAllTextAsync(ingressPath);
// Should have one host rule with two paths
Assert.Contains("example.com", content);
Assert.Contains("/api", content);
Assert.Contains("path: \"/\"", content);
}
[Fact]
public async Task AddIngress_WithAnnotations_GeneratesAnnotations()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public")
.WithIngressAnnotation("nginx.ingress.kubernetes.io/rewrite-target", "/$1");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
ingress.WithPath("/", api.GetEndpoint("http"));
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var content = await File.ReadAllTextAsync(ingressPath);
Assert.Contains("nginx.ingress.kubernetes.io/rewrite-target", content);
}
[Fact]
public async Task AddIngress_WithExactPathType_GeneratesExactPathType()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
ingress.WithPath("/exact", api.GetEndpoint("http"), IngressPathType.Exact);
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var content = await File.ReadAllTextAsync(ingressPath);
Assert.Contains("Exact", content);
}
[Fact]
public async Task AddIngress_NoPathsWithTls_DoesNotRegisterTlsBootstrapStep()
{
// An ingress with no paths and no default backend is skipped during materialization, so
// collecting its TLS secret would bootstrap a self-signed cert for an Ingress that is
// never created, leaving an orphaned secret in the cluster.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
k8s.AddIngress("empty")
.WithHostname("api.example.com")
.WithTls("my-tls-secret");
builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
using var app = builder.Build();
app.Run();
var ingressDir = Path.Combine(workspace.Path, "templates", "empty");
Assert.False(Directory.Exists(ingressDir), $"Ingress directory should not exist at {ingressDir}");
var steps = await PipelineStepTestHelpers.CreateStepsAsync(app.Services, k8s.Resource);
Assert.Empty(PipelineStepTestHelpers.GatewayOrTlsStepNames(steps));
}
[Fact]
public async Task AddIngress_NoPaths_WarnsThatIngressAndTlsAreSkipped()
{
// The warning is the only signal a user gets that their Ingress (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.AddIngress("empty")
.WithHostname("api.example.com")
.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(
"Ingress 'empty' has no path rules or default backend configured. The Ingress and its TLS certificate will not be created.",
warning.Message);
}
[Fact]
public async Task AddIngress_UnresolvableBackendWithTls_NotEligibleForTlsBootstrap()
{
// An Ingress can be configured with paths (so it passes the publish-time materialization
// check) and still be dropped from the chart when none of its backends resolve to a
// deployment target. Bootstrapping its certificate anyway would leave an orphaned secret in
// the cluster, so eligibility is re-checked at deploy time.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var otherEnvironment = builder.AddKubernetesEnvironment("other");
// Assigning the container to a different environment keeps it out of "env"'s deployment
// targets, so the Ingress backend cannot be resolved.
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints()
.WithComputeEnvironment(otherEnvironment);
var ingress = k8s.AddIngress("public")
.WithHostname("api.example.com")
.WithTls("my-tls-secret");
ingress.WithPath("/", api.GetEndpoint("http"));
using var app = builder.Build();
app.Run();
var ingressDir = Path.Combine(workspace.Path, "templates", "public");
Assert.False(Directory.Exists(ingressDir), $"Ingress directory should not exist at {ingressDir}");
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
// The secret is still collected, because step factories run before publish populates the
// generated objects and therefore cannot know the Ingress will be dropped.
var collected = KubernetesEnvironmentResource.CollectTlsSecrets(model, k8s.Resource);
var request = Assert.Single(collected);
Assert.Same(ingress.Resource, request.Owner);
// The deploy-time re-check is what prevents the orphaned secret.
Assert.False(KubernetesEnvironmentResource.OwnerWasMaterialized(request.Owner));
}
[Fact]
public async Task AddIngress_ResolvableBackendWithTls_EligibleForTlsBootstrap()
{
// Positive control for AddIngress_UnresolvableBackendWithTls_NotEligibleForTlsBootstrap:
// an Ingress that does render must remain eligible, so the deploy-time re-check cannot
// silently suppress every certificate.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
var ingress = k8s.AddIngress("public")
.WithHostname("api.example.com")
.WithTls("my-tls-secret");
ingress.WithPath("/", api.GetEndpoint("http"));
using var app = builder.Build();
app.Run();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var collected = KubernetesEnvironmentResource.CollectTlsSecrets(model, k8s.Resource);
var request = Assert.Single(collected);
Assert.Same(ingress.Resource, request.Owner);
Assert.True(KubernetesEnvironmentResource.OwnerWasMaterialized(request.Owner));
}
[Fact]
public async Task CollectTlsSecrets_BeforePublish_DoesNotDependOnGeneratedIngress()
{
// Guards the trap that makes this area easy to "fix" incorrectly. In production the pipeline
// builds every step before running any of them, so CollectTlsSecrets always executes while
// GeneratedIngress is still null. Moving the materialization check into collection would
// therefore disable TLS bootstrap for *every* Ingress, yet the other tests here would not
// notice, because they inspect collection after app.Run() has already populated it.
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
var ingress = k8s.AddIngress("public")
.WithHostname("api.example.com")
.WithTls("my-tls-secret");
ingress.WithPath("/", api.GetEndpoint("http"));
using var app = builder.Build();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
Assert.Null(ingress.Resource.GeneratedIngress);
var collectedBeforePublish = KubernetesEnvironmentResource.CollectTlsSecrets(model, k8s.Resource);
Assert.Same(ingress.Resource, Assert.Single(collectedBeforePublish).Owner);
// The Ingress does render, so the deploy-time re-check still lets the bootstrap through.
app.Run();
Assert.NotNull(ingress.Resource.GeneratedIngress);
Assert.True(KubernetesEnvironmentResource.OwnerWasMaterialized(ingress.Resource));
}
[Fact]
public async Task AddIngress_NoPaths_DoesNotGenerateYaml()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
k8s.AddIngress("empty");
builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
var app = builder.Build();
app.Run();
// No ingress file should exist for empty ingress
var ingressDir = Path.Combine(workspace.Path, "templates", "empty");
Assert.False(Directory.Exists(ingressDir), $"Ingress directory should not exist at {ingressDir}");
}
[Fact]
public async Task AddIngress_BackwardCompatible_NoIngressNoChange()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
// No ingress defined at all - should work as before
builder.AddKubernetesEnvironment("env");
builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
var app = builder.Build();
app.Run();
// Service and deployment should exist but no ingress
var templatesDir = Path.Combine(workspace.Path, "templates", "myapi");
Assert.True(Directory.Exists(templatesDir));
var files = Directory.GetFiles(templatesDir);
Assert.DoesNotContain(files, f => f.Contains("ingress", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task AddIngress_MultipleIngresses_GeneratesSeparateYaml()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var publicIngress = k8s.AddIngress("public-ingress")
.WithIngressClass("nginx");
var internalIngress = k8s.AddIngress("internal-ingress")
.WithIngressClass("internal");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
var admin = builder.AddContainer("myadmin", "nginx")
.WithHttpEndpoint(targetPort: 9090)
.WithExternalHttpEndpoints();
publicIngress.WithPath("/", api.GetEndpoint("http"));
internalIngress.WithPath("/admin", admin.GetEndpoint("http"));
var app = builder.Build();
app.Run();
// Both ingresses should have their own template directories
var publicPath = Path.Combine(workspace.Path, "templates", "public-ingress", "public-ingress.yaml");
var internalPath = Path.Combine(workspace.Path, "templates", "internal-ingress", "internal-ingress.yaml");
Assert.True(File.Exists(publicPath), $"Public ingress YAML not found at {publicPath}");
Assert.True(File.Exists(internalPath), $"Internal ingress YAML not found at {internalPath}");
var publicContent = await File.ReadAllTextAsync(publicPath);
var internalContent = await File.ReadAllTextAsync(internalPath);
Assert.Contains("nginx", publicContent);
Assert.Contains("internal", internalContent);
}
[Fact]
public async Task AddIngress_TlsWithDefaultBackend_AutoGeneratesHostRule()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
var web = builder.AddContainer("myweb", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
// TLS host + default backend but NO explicit path for the TLS host.
// The ingress should auto-generate a rule for the TLS host.
ingress
.WithDefaultBackend(web.GetEndpoint("http"))
.WithHostname("app.example.com").WithTls("my-tls-secret");
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
Assert.True(File.Exists(ingressPath), $"Expected ingress YAML at {ingressPath}");
var content = await File.ReadAllTextAsync(ingressPath);
// Should have TLS config
Assert.Contains("my-tls-secret", content);
Assert.Contains("app.example.com", content);
// Should have auto-generated rule for the TLS host
Assert.Contains("Prefix", content);
}
[Fact]
public async Task AddIngress_TlsWithExplicitPath_DoesNotDuplicate()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
var web = builder.AddContainer("myweb", "nginx")
.WithHttpEndpoint(targetPort: 8080)
.WithExternalHttpEndpoints();
// Explicit path for the TLS host -- should NOT auto-generate another one
ingress
.WithPath("app.example.com", "/", web.GetEndpoint("http"))
.WithDefaultBackend(web.GetEndpoint("http"))
.WithHostname("app.example.com").WithTls("my-tls-secret");
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
var content = await File.ReadAllTextAsync(ingressPath);
// Count occurrences of the host rule ΓÇö should appear exactly once
var hostCount = content.Split("app.example.com").Length - 1;
// TLS hosts list + one rule = 2 occurrences (not 3 which would mean duplicate rule)
Assert.Equal(2, hostCount);
}
[Fact]
public void WithPath_InvalidPath_Throws()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
Assert.Throws<ArgumentException>(() =>
ingress.WithPath("no-leading-slash", api.GetEndpoint("http")));
}
[Fact]
public void WithTls_NoArg_GeneratesSecretName()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
ingress.WithTls();
Assert.Single(ingress.Resource.TlsConfigs);
Assert.Equal("public-tls", ingress.Resource.TlsConfigs[0].SecretName.Format);
}
[Fact]
public void AddIngress_ResourceType_HasCorrectParent()
{
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
Assert.Equal(k8s.Resource, ingress.Resource.Parent);
Assert.IsType<KubernetesIngressResource>(ingress.Resource);
}
[Fact]
public void AddIngress_WithPath_NonExternalEndpoint_ThrowsOnPublish()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.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.WithPath("/", 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 AddIngress_WithDefaultBackend_NonExternalEndpoint_ThrowsOnPublish()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.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<AggregateException>(app.Run);
var ex = aggregate.Flatten().InnerExceptions.OfType<InvalidOperationException>().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_WithPath_ExternalEndpoint_Succeeds()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
var k8s = builder.AddKubernetesEnvironment("env");
var ingress = k8s.AddIngress("public");
// WithExternalHttpEndpoints applied AFTER WithPath to demonstrate that
// authoring order does not matter: validation runs at publish time.
var api = builder.AddContainer("myapi", "nginx")
.WithHttpEndpoint(targetPort: 8080);
ingress.WithPath("/", api.GetEndpoint("http"));
api.WithExternalHttpEndpoints();
var app = builder.Build();
app.Run();
var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
Assert.True(File.Exists(ingressPath));
}
}