diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs
index 6ccc245d316..e064faf73f1 100644
--- a/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs
+++ b/src/Aspire.Hosting.Kubernetes/KubernetesEnvironmentResource.cs
@@ -32,6 +32,9 @@ namespace Aspire.Hosting.Kubernetes;
[AspireExport(ExposeProperties = true)]
public sealed class KubernetesEnvironmentResource : Resource, IComputeEnvironmentResource
{
+ private const int HttpRouteHostnameLimit = 16;
+ private const string HttpRouteSpecDocumentationUrl = "https://gateway-api.sigs.k8s.io/reference/api-spec/main/spec/#httproutespec";
+
///
/// Gets or sets the name of the Helm chart to be generated.
///
@@ -580,8 +583,8 @@ private static async Task ResolveExpressionAtDeployTimeAsync(ReferenceEx
///
/// Resolves a for inclusion in a Kubernetes manifest
/// produced by an ingress or gateway resource. When the expression wraps one or more
- /// instances that have no value at publish time
- /// (e.g., user-supplied parameters without defaults, or secrets), the expression is
+ /// instances that must remain deploy-time inputs
+ /// (for example, secrets or parameters without published defaults), the expression is
/// rendered with Helm template placeholders (such as {{ .Values.parameters.ingress.ingressclass }})
/// and the parameters are captured for deploy-time resolution into a values override file.
///
@@ -594,31 +597,33 @@ private static async Task ResolveExpressionAtDeployTimeAsync(ReferenceEx
/// The cancellation token.
private async Task ResolveExpressionAsync(ReferenceExpression expression, string owningResourceName, CancellationToken cancellationToken)
{
- try
+ if (!expression.ValueProviders.OfType().Any(ShouldCaptureAsHelmValue))
{
- return (await expression.GetValueAsync(cancellationToken).ConfigureAwait(false))!;
- }
- catch (MissingParameterValueException)
- {
- // One or more parameters in the expression have no value at publish time
- // (e.g., a parameter created via AddParameter("ingressclass") with no default,
- // or a secret parameter). Substitute each unresolved parameter with a Helm
- // template reference into values.yaml so the resulting manifest is a valid
- // Helm template and the value can be supplied at deploy time.
- var owningResourceKey = owningResourceName.ToHelmValuesSectionName();
- var args = new object[expression.ValueProviders.Count];
-
- for (var i = 0; i < expression.ValueProviders.Count; i++)
+ try
{
- args[i] = await ResolveValueProviderAsync(
- expression.ValueProviders[i],
- owningResourceName,
- owningResourceKey,
- cancellationToken).ConfigureAwait(false);
+ return (await expression.GetValueAsync(cancellationToken).ConfigureAwait(false))!;
}
+ catch (MissingParameterValueException)
+ {
+ // Fall through to Helm-reference substitution below.
+ }
+ }
- return string.Format(System.Globalization.CultureInfo.InvariantCulture, expression.Format, args);
+ // Parameters without published defaults may still have runtime values, but publish must
+ // preserve them as deploy-time inputs instead of leaking those values into generated YAML.
+ var owningResourceKey = owningResourceName.ToHelmValuesSectionName();
+ var args = new object[expression.ValueProviders.Count];
+
+ for (var i = 0; i < expression.ValueProviders.Count; i++)
+ {
+ args[i] = await ResolveValueProviderAsync(
+ expression.ValueProviders[i],
+ owningResourceName,
+ owningResourceKey,
+ cancellationToken).ConfigureAwait(false);
}
+
+ return string.Format(System.Globalization.CultureInfo.InvariantCulture, expression.Format, args);
}
private async Task ResolveValueProviderAsync(
@@ -629,19 +634,16 @@ private async Task ResolveValueProviderAsync(
{
if (valueProvider is ParameterResource parameter)
{
- // Attempt to resolve this individual parameter first. The outer
- // MissingParameterValueException from the whole-expression resolve attempt
- // only tells us that *some* parameter in the expression was unresolved;
- // others (e.g., those with `publishValueAsDefault: true`) may still have
- // a value and should be inlined into the manifest rather than left as
- // Helm placeholders. This keeps the published chart maximally self-contained.
- try
+ if (!ShouldCaptureAsHelmValue(parameter))
{
- return (await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false)) ?? string.Empty;
- }
- catch (MissingParameterValueException)
- {
- // Fall through to Helm-reference substitution below.
+ try
+ {
+ return (await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false)) ?? string.Empty;
+ }
+ catch (MissingParameterValueException)
+ {
+ // Fall through to Helm-reference substitution below.
+ }
}
// Capture the parameter so HelmDeploymentEngine writes its resolved value to
@@ -673,6 +675,24 @@ private async Task ResolveValueProviderAsync(
return (await valueProvider.GetValueAsync(cancellationToken).ConfigureAwait(false)) ?? string.Empty;
}
+ private static bool ShouldCaptureAsHelmValue(ParameterResource parameter)
+ => parameter.Secret || parameter.Default is null;
+
+ private async Task> ResolveHostnamesAsync(
+ IEnumerable hostnames,
+ string owningResourceName,
+ CancellationToken cancellationToken)
+ {
+ var resolvedHostnames = new List();
+
+ foreach (var hostname in hostnames)
+ {
+ resolvedHostnames.Add(await ResolveExpressionAsync(hostname, owningResourceName, cancellationToken).ConfigureAwait(false));
+ }
+
+ return resolvedHostnames;
+ }
+
private async Task ProcessIngressResources(DistributedApplicationModel model, Dictionary deploymentTargets, ILogger logger, CancellationToken cancellationToken)
{
var ingressResources = model.Resources
@@ -735,17 +755,40 @@ private async Task ProcessIngressResources(DistributedApplicationModel model, Di
ingress.Metadata.Annotations[key] = await ResolveExpressionAsync(value, ingressResource.Name, cancellationToken).ConfigureAwait(false);
}
- var pathsByHost = ingressResource.Paths.GroupBy(p => p.Host ?? string.Empty);
+ var resolvedHostnames = await ResolveHostnamesAsync(
+ ingressResource.Hostnames,
+ ingressResource.Name,
+ cancellationToken).ConfigureAwait(false);
+ var pathsByHost = new Dictionary>();
- foreach (var hostGroup in pathsByHost)
+ foreach (var path in ingressResource.Paths)
+ {
+ if (path.Host is { } explicitHost)
+ {
+ AddPathForHost(explicitHost, path);
+ }
+ else if (resolvedHostnames.Count == 0)
+ {
+ AddPathForHost(string.Empty, path);
+ }
+ else
+ {
+ foreach (var hostname in resolvedHostnames)
+ {
+ AddPathForHost(hostname, path);
+ }
+ }
+ }
+
+ foreach (var (host, paths) in pathsByHost)
{
var rule = new IngressRuleV1();
- if (!string.IsNullOrEmpty(hostGroup.Key))
+ if (!string.IsNullOrEmpty(host))
{
- rule.Host = hostGroup.Key;
+ rule.Host = host;
}
- foreach (var pathRule in hostGroup)
+ foreach (var pathRule in paths)
{
var backend = ResolveIngressBackend(pathRule.Endpoint, deploymentTargets, ingressResource.Name, logger);
if (backend is null)
@@ -783,16 +826,14 @@ private async Task ProcessIngressResources(DistributedApplicationModel model, Di
SecretName = await ResolveExpressionAsync(tls.SecretName, ingressResource.Name, cancellationToken).ConfigureAwait(false),
};
- foreach (var host in ingressResource.Hostnames)
- {
- tlsEntry.Hosts.Add(await ResolveExpressionAsync(host, ingressResource.Name, cancellationToken).ConfigureAwait(false));
- }
+ tlsEntry.Hosts.AddRange(resolvedHostnames);
ingress.Spec.Tls.Add(tlsEntry);
}
- // Auto-generate rules for TLS hosts that don't have explicit routes.
- if (ingress.Spec.DefaultBackend is not null)
+ // A default backend remains catch-all even when hostnames are configured. Only synthesize
+ // host rules for TLS because some ingress controllers require each TLS host to have a rule.
+ if (ingress.Spec.DefaultBackend is not null && ingress.Spec.Tls.Count > 0)
{
var hostsWithRules = new HashSet(
ingress.Spec.Rules
@@ -800,41 +841,37 @@ private async Task ProcessIngressResources(DistributedApplicationModel model, Di
.Select(r => r.Host!),
StringComparer.OrdinalIgnoreCase);
- foreach (var tls in ingressResource.TlsConfigs)
+ foreach (var resolvedHost in resolvedHostnames)
{
- foreach (var host in ingressResource.Hostnames)
+ if (hostsWithRules.Add(resolvedHost))
{
- var resolvedHost = await ResolveExpressionAsync(host, ingressResource.Name, cancellationToken).ConfigureAwait(false);
- if (!hostsWithRules.Contains(resolvedHost))
+ ingress.Spec.Rules.Add(new IngressRuleV1
{
- ingress.Spec.Rules.Add(new IngressRuleV1
+ Host = resolvedHost,
+ Http = new HttpIngressRuleValueV1
{
- Host = resolvedHost,
- Http = new HttpIngressRuleValueV1
+ Paths =
{
- Paths =
+ new HttpIngressPathV1
{
- new HttpIngressPathV1
+ Path = "/",
+ PathType = IngressPathType.Prefix.ToKubernetesString(),
+ Backend = new IngressBackendV1
{
- Path = "/",
- PathType = IngressPathType.Prefix.ToKubernetesString(),
- Backend = new IngressBackendV1
+ Service = new IngressServiceBackendV1
{
- Service = new IngressServiceBackendV1
+ Name = ingress.Spec.DefaultBackend.Service.Name,
+ Port = new ServiceBackendPortV1
{
- Name = ingress.Spec.DefaultBackend.Service.Name,
- Port = new ServiceBackendPortV1
- {
- Name = ingress.Spec.DefaultBackend.Service.Port.Name,
- Number = ingress.Spec.DefaultBackend.Service.Port.Number
- }
+ Name = ingress.Spec.DefaultBackend.Service.Port.Name,
+ Number = ingress.Spec.DefaultBackend.Service.Port.Number
}
}
}
}
}
- });
- }
+ }
+ });
}
}
}
@@ -846,6 +883,17 @@ private async Task ProcessIngressResources(DistributedApplicationModel model, Di
}
return ingress;
+
+ void AddPathForHost(string host, IngressPathConfig path)
+ {
+ if (!pathsByHost.TryGetValue(host, out var paths))
+ {
+ paths = [];
+ pathsByHost.Add(host, paths);
+ }
+
+ paths.Add(path);
+ }
}
private static IngressBackendV1? ResolveIngressBackend(
@@ -1000,6 +1048,20 @@ private async Task BuildGatewayObjects(
{
Metadata = { Name = gatewayName }
};
+ var resolvedHostnames = await ResolveHostnamesAsync(
+ gatewayResource.Hostnames,
+ gatewayResource.Name,
+ cancellationToken).ConfigureAwait(false);
+
+ if (resolvedHostnames.Count > HttpRouteHostnameLimit &&
+ gatewayResource.Routes.Any(route => route.Host is null))
+ {
+ throw new InvalidOperationException(
+ $"Gateway '{gatewayResource.Name}' configures {resolvedHostnames.Count} hostnames that would be inherited by a hostless route, " +
+ $"but Kubernetes Gateway API HTTPRoute.spec.hostnames supports at most {HttpRouteHostnameLimit} entries. " +
+ $"Define explicit host-scoped routes with WithRoute(hostname, path, endpoint) so each HTTPRoute stays within the limit. " +
+ $"See the Kubernetes Gateway API documentation: {HttpRouteSpecDocumentationUrl}");
+ }
gateway.Spec.GatewayClassName = await ResolveExpressionAsync(gatewayResource.GatewayClassName, gatewayResource.Name, cancellationToken).ConfigureAwait(false);
@@ -1024,7 +1086,7 @@ private async Task BuildGatewayObjects(
{
var resolvedSecretName = await ResolveExpressionAsync(tls.SecretName, gatewayResource.Name, cancellationToken).ConfigureAwait(false);
- if (gatewayResource.Hostnames.Count == 0)
+ if (resolvedHostnames.Count == 0)
{
// No hostnames specified — create an HTTPS listener without a hostname restriction.
// The hostname will be discovered from the Gateway's assigned address after deployment
@@ -1050,13 +1112,11 @@ private async Task BuildGatewayObjects(
}
else
{
- foreach (var host in gatewayResource.Hostnames)
+ foreach (var resolvedHost in resolvedHostnames)
{
var listenerName = tlsListenerIndex == 0 ? "https" : $"https-{tlsListenerIndex}";
tlsListenerIndex++;
- var resolvedHost = await ResolveExpressionAsync(host, gatewayResource.Name, cancellationToken).ConfigureAwait(false);
-
gateway.Spec.Listeners.Add(new GatewayListenerV1
{
Name = listenerName,
@@ -1098,6 +1158,10 @@ private async Task BuildGatewayObjects(
{
httpRoute.Spec.Hostnames.Add(hostGroup.Key);
}
+ else
+ {
+ httpRoute.Spec.Hostnames.AddRange(resolvedHostnames);
+ }
foreach (var route in hostGroup)
{
diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesGatewayExtensions.cs b/src/Aspire.Hosting.Kubernetes/KubernetesGatewayExtensions.cs
index 4e1853361e4..2e42fded9a2 100644
--- a/src/Aspire.Hosting.Kubernetes/KubernetesGatewayExtensions.cs
+++ b/src/Aspire.Hosting.Kubernetes/KubernetesGatewayExtensions.cs
@@ -89,9 +89,10 @@ public static IResourceBuilder WithGatewayClass(
}
///
- /// Adds a path-based routing rule to the gateway. The rule matches all hosts and routes
- /// traffic matching the specified path to the given endpoint's backing Kubernetes service.
- /// This generates an HTTPRoute resource attached to the Gateway.
+ /// Adds a path-based routing rule to the gateway. The rule matches each hostname configured
+ /// with , or all
+ /// hosts when no hostname is configured, and routes matching traffic to the endpoint's backing
+ /// Kubernetes service. This generates an HTTPRoute resource attached to the Gateway.
///
/// The gateway resource builder.
/// The URL path to match (e.g., "/" or "/api"). Must start with /.
@@ -165,8 +166,9 @@ public static IResourceBuilder WithRoute(
///
/// Adds a hostname that this gateway's routes match. Multiple hostnames can be added by calling
- /// this method repeatedly. Hostnames are used as hostnames in generated HTTPRoute
- /// resources and as HTTPS listener hostnames when TLS is configured.
+ /// this method repeatedly. Routes without an explicit host apply to each configured hostname.
+ /// Hostnames are used as hostnames in generated HTTPRoute resources and as HTTPS
+ /// listener hostnames when TLS is configured.
///
/// The gateway resource builder.
/// The hostname to match (e.g., "api.example.com").
diff --git a/src/Aspire.Hosting.Kubernetes/KubernetesIngressExtensions.cs b/src/Aspire.Hosting.Kubernetes/KubernetesIngressExtensions.cs
index 092ad05c59e..648127e1662 100644
--- a/src/Aspire.Hosting.Kubernetes/KubernetesIngressExtensions.cs
+++ b/src/Aspire.Hosting.Kubernetes/KubernetesIngressExtensions.cs
@@ -99,8 +99,9 @@ public static IResourceBuilder WithIngressClass(
}
///
- /// Adds a path-based rule to the ingress. The rule matches all hosts and forwards
- /// traffic matching the specified path to the given endpoint's backing Kubernetes service.
+ /// Adds a path-based rule to the ingress. The rule matches each hostname configured with
+ /// , or all hosts
+ /// when no hostname is configured, and forwards matching traffic to the endpoint's backing Kubernetes service.
///
/// The ingress resource builder.
/// The URL path to match (e.g., "/" or "/api"). Must start with /.
@@ -185,7 +186,8 @@ public static IResourceBuilder WithPath(
///
/// Adds a hostname that this ingress matches. Multiple hostnames can be added by calling
- /// this method repeatedly. If no hostnames are configured, the ingress matches all hosts.
+ /// this method repeatedly. Path rules without an explicit host apply to each configured
+ /// hostname. If no hostnames are configured, those rules match all hosts.
///
/// The ingress resource builder.
/// The hostname to match (e.g., "api.example.com").
@@ -300,6 +302,10 @@ public static IResourceBuilder WithTls(
/// The endpoint reference identifying the default backend service and port.
/// A reference to the for chaining.
/// The resource builder.
+ ///
+ /// Kubernetes default backends remain catch-all even when hostnames are configured. Use host-specific
+ /// path rules when traffic must be restricted by hostname.
+ ///
[AspireExport]
public static IResourceBuilder WithDefaultBackend(
this IResourceBuilder builder,
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs
index ff0121b418c..3c593c2de54 100644
--- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesGatewayTests.cs
@@ -81,6 +81,157 @@ public async Task AddGateway_WithHostRoute_GeneratesHostnameInHttpRoute()
Assert.Contains("HTTPRoute", content);
}
+ [Fact]
+ public async Task AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
+
+ var hostname = builder.AddParameter("hostname", "localhost");
+ var k8s = builder.AddKubernetesEnvironment("env");
+ var gateway = k8s.AddGateway("public")
+ .WithGatewayClass("nginx")
+ .WithHostname(hostname)
+ .WithTls();
+
+ var api = builder.AddContainer("myapi", "nginx")
+ .WithHttpEndpoint(targetPort: 8080)
+ .WithExternalHttpEndpoints();
+
+ gateway.WithRoute("/api", api.GetEndpoint("http"));
+
+ using var app = builder.Build();
+ app.Run();
+
+ var gatewayPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
+ var routePath = Path.Combine(workspace.Path, "templates", "public", "route.yaml");
+ var valuesPath = Path.Combine(workspace.Path, "values.yaml");
+
+ await Verify(File.ReadAllText(gatewayPath), "yaml")
+ .AppendContentAsFile(File.ReadAllText(routePath), "yaml")
+ .AppendContentAsFile(File.ReadAllText(valuesPath), "yaml");
+ }
+
+ [Fact]
+ public async Task AddGateway_WithHostname_AppliesToHostlessRoute()
+ {
+ 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")
+ .WithHostname("api.example.com")
+ .WithHostname("www.example.com");
+
+ var api = builder.AddContainer("myapi", "nginx")
+ .WithHttpEndpoint(targetPort: 8080)
+ .WithExternalHttpEndpoints();
+
+ gateway.WithRoute("/api", api.GetEndpoint("http"));
+
+ using var app = builder.Build();
+ app.Run();
+
+ var routePath = Path.Combine(workspace.Path, "templates", "public", "route.yaml");
+
+ await Verify(File.ReadAllText(routePath), "yaml");
+ }
+
+ [Fact]
+ public void AddGateway_WithRoute_InheritsMaximumSupportedHostnames()
+ {
+ 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 expectedHostnames = Enumerable.Range(1, 16)
+ .Select(index => $"host-{index}.example.com")
+ .ToArray();
+
+ foreach (var hostname in expectedHostnames)
+ {
+ gateway.WithHostname(hostname);
+ }
+
+ var api = builder.AddContainer("myapi", "nginx")
+ .WithHttpEndpoint(targetPort: 8080)
+ .WithExternalHttpEndpoints();
+
+ gateway.WithRoute("/", api.GetEndpoint("http"));
+
+ using var app = builder.Build();
+ app.Run();
+
+ var route = Assert.Single(gateway.Resource.GeneratedHttpRoutes);
+ Assert.Equal(expectedHostnames, route.Spec.Hostnames);
+ }
+
+ [Fact]
+ public void AddGateway_WithRoute_ExceedingMaximumSupportedHostnames_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");
+
+ foreach (var index in Enumerable.Range(1, 17))
+ {
+ gateway.WithHostname($"host-{index}.example.com");
+ }
+
+ var api = builder.AddContainer("myapi", "nginx")
+ .WithHttpEndpoint(targetPort: 8080)
+ .WithExternalHttpEndpoints();
+
+ gateway.WithRoute("/", api.GetEndpoint("http"));
+
+ using var app = builder.Build();
+ var aggregate = Assert.Throws(app.Run);
+ var exception = aggregate.Flatten().InnerExceptions
+ .Select(e => e.InnerException)
+ .OfType()
+ .First(e => e.Message.StartsWith("Gateway 'public'", StringComparison.Ordinal));
+
+ Assert.Equal(
+ "Gateway 'public' configures 17 hostnames that would be inherited by a hostless route, " +
+ "but Kubernetes Gateway API HTTPRoute.spec.hostnames supports at most 16 entries. " +
+ "Define explicit host-scoped routes with WithRoute(hostname, path, endpoint) so each HTTPRoute stays within the limit. " +
+ "See the Kubernetes Gateway API documentation: https://gateway-api.sigs.k8s.io/reference/api-spec/main/spec/#httproutespec",
+ exception.Message);
+ }
+
+ [Fact]
+ public void AddGateway_WithHostRoutes_DoesNotApplyInheritedHostnameLimit()
+ {
+ 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 hostnames = Enumerable.Range(1, 17)
+ .Select(index => $"host-{index}.example.com")
+ .ToArray();
+
+ var api = builder.AddContainer("myapi", "nginx")
+ .WithHttpEndpoint(targetPort: 8080)
+ .WithExternalHttpEndpoints();
+
+ foreach (var hostname in hostnames)
+ {
+ gateway.WithHostname(hostname);
+ gateway.WithRoute(hostname, "/", api.GetEndpoint("http"));
+ }
+
+ using var app = builder.Build();
+ app.Run();
+
+ Assert.Equal(hostnames.Length, gateway.Resource.GeneratedHttpRoutes.Count);
+ Assert.All(gateway.Resource.GeneratedHttpRoutes, route => Assert.Single(route.Spec.Hostnames));
+ }
+
[Fact]
public async Task AddGateway_WithTls_GeneratesHttpsListener()
{
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesIngressTests.cs b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesIngressTests.cs
index 2edaa3381d5..bb68b72b671 100644
--- a/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesIngressTests.cs
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/KubernetesIngressTests.cs
@@ -153,6 +153,83 @@ public async Task AddIngress_WithIngressClassParameter_WithDefaultValue_Resolves
Assert.DoesNotContain("{{ .Values", content);
}
+ [Fact]
+ public async Task AddIngress_WithRuntimeOnlyHostnameParameter_DefersValue()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
+
+ var hostname = builder.AddParameter("hostname", "localhost");
+ var k8s = builder.AddKubernetesEnvironment("env");
+ var ingress = k8s.AddIngress("public")
+ .WithHostname(hostname)
+ .WithTls();
+
+ var api = builder.AddContainer("myapi", "nginx")
+ .WithHttpEndpoint(targetPort: 8080)
+ .WithExternalHttpEndpoints();
+
+ ingress.WithPath("/api", api.GetEndpoint("http"));
+
+ using var app = builder.Build();
+ app.Run();
+
+ var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
+ var valuesPath = Path.Combine(workspace.Path, "values.yaml");
+
+ await Verify(File.ReadAllText(ingressPath), "yaml")
+ .AppendContentAsFile(File.ReadAllText(valuesPath), "yaml");
+ }
+
+ [Fact]
+ public async Task AddIngress_WithHostname_AppliesToHostlessPath()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
+
+ var k8s = builder.AddKubernetesEnvironment("env");
+ var ingress = k8s.AddIngress("public")
+ .WithHostname("api.example.com")
+ .WithHostname("www.example.com");
+
+ var api = builder.AddContainer("myapi", "nginx")
+ .WithHttpEndpoint(targetPort: 8080)
+ .WithExternalHttpEndpoints();
+
+ ingress.WithPath("/api", api.GetEndpoint("http"));
+
+ using var app = builder.Build();
+ app.Run();
+
+ var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
+
+ await Verify(File.ReadAllText(ingressPath), "yaml");
+ }
+
+ [Fact]
+ public async Task AddIngress_HostnameWithDefaultBackendWithoutTls_DoesNotGenerateHostRule()
+ {
+ using var workspace = TemporaryWorkspace.Create(outputHelper);
+ var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);
+
+ var k8s = builder.AddKubernetesEnvironment("env");
+ var ingress = k8s.AddIngress("public")
+ .WithHostname("api.example.com");
+
+ var api = builder.AddContainer("myapi", "nginx")
+ .WithHttpEndpoint(targetPort: 8080)
+ .WithExternalHttpEndpoints();
+
+ ingress.WithDefaultBackend(api.GetEndpoint("http"));
+
+ using var app = builder.Build();
+ app.Run();
+
+ var ingressPath = Path.Combine(workspace.Path, "templates", "public", "public.yaml");
+
+ await Verify(File.ReadAllText(ingressPath), "yaml");
+ }
+
[Fact]
public async Task AddIngress_WithTls_GeneratesTlsSection()
{
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithHostname_AppliesToHostlessRoute.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithHostname_AppliesToHostlessRoute.verified.yaml
new file mode 100644
index 00000000000..aa9c11e64b3
--- /dev/null
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithHostname_AppliesToHostlessRoute.verified.yaml
@@ -0,0 +1,19 @@
+---
+apiVersion: "gateway.networking.k8s.io/v1"
+kind: "HTTPRoute"
+metadata:
+ name: "public-route"
+spec:
+ parentRefs:
+ - name: "public"
+ hostnames:
+ - "api.example.com"
+ - "www.example.com"
+ rules:
+ - matches:
+ - path:
+ type: "PathPrefix"
+ value: "/api"
+ backendRefs:
+ - name: "myapi-service"
+ port: 8080
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue#00.verified.yaml
new file mode 100644
index 00000000000..5d0e5c4d745
--- /dev/null
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue#00.verified.yaml
@@ -0,0 +1,25 @@
+---
+apiVersion: "gateway.networking.k8s.io/v1"
+kind: "Gateway"
+metadata:
+ name: "public"
+spec:
+ gatewayClassName: "nginx"
+ listeners:
+ - name: "http"
+ protocol: "HTTP"
+ port: 80
+ allowedRoutes:
+ namespaces:
+ from: "Same"
+ - name: "https"
+ protocol: "HTTPS"
+ port: 443
+ hostname: "{{ .Values.parameters.public.hostname }}"
+ tls:
+ mode: "Terminate"
+ certificateRefs:
+ - name: "public-tls"
+ allowedRoutes:
+ namespaces:
+ from: "Same"
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue#01.verified.yaml
new file mode 100644
index 00000000000..d307f845ecf
--- /dev/null
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue#01.verified.yaml
@@ -0,0 +1,18 @@
+---
+apiVersion: "gateway.networking.k8s.io/v1"
+kind: "HTTPRoute"
+metadata:
+ name: "public-route"
+spec:
+ parentRefs:
+ - name: "public"
+ hostnames:
+ - "{{ .Values.parameters.public.hostname }}"
+ rules:
+ - matches:
+ - path:
+ type: "PathPrefix"
+ value: "/api"
+ backendRefs:
+ - name: "myapi-service"
+ port: 8080
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue#02.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue#02.verified.yaml
new file mode 100644
index 00000000000..6d621975b1f
--- /dev/null
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesGatewayTests.AddGateway_WithRuntimeOnlyHostnameParameter_DefersValue#02.verified.yaml
@@ -0,0 +1,5 @@
+parameters:
+ public:
+ hostname: ""
+secrets: {}
+config: {}
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_HostnameWithDefaultBackendWithoutTls_DoesNotGenerateHostRule.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_HostnameWithDefaultBackendWithoutTls_DoesNotGenerateHostRule.verified.yaml
new file mode 100644
index 00000000000..c240fa25066
--- /dev/null
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_HostnameWithDefaultBackendWithoutTls_DoesNotGenerateHostRule.verified.yaml
@@ -0,0 +1,11 @@
+---
+apiVersion: "networking.k8s.io/v1"
+kind: "Ingress"
+metadata:
+ name: "public"
+spec:
+ defaultBackend:
+ service:
+ name: "myapi-service"
+ port:
+ name: "http"
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_WithHostname_AppliesToHostlessPath.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_WithHostname_AppliesToHostlessPath.verified.yaml
new file mode 100644
index 00000000000..bf20dde98aa
--- /dev/null
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_WithHostname_AppliesToHostlessPath.verified.yaml
@@ -0,0 +1,27 @@
+---
+apiVersion: "networking.k8s.io/v1"
+kind: "Ingress"
+metadata:
+ name: "public"
+spec:
+ rules:
+ - http:
+ paths:
+ - backend:
+ service:
+ name: "myapi-service"
+ port:
+ name: "http"
+ pathType: "Prefix"
+ path: "/api"
+ host: "api.example.com"
+ - http:
+ paths:
+ - backend:
+ service:
+ name: "myapi-service"
+ port:
+ name: "http"
+ pathType: "Prefix"
+ path: "/api"
+ host: "www.example.com"
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_WithRuntimeOnlyHostnameParameter_DefersValue#00.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_WithRuntimeOnlyHostnameParameter_DefersValue#00.verified.yaml
new file mode 100644
index 00000000000..5c38ab91bf0
--- /dev/null
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_WithRuntimeOnlyHostnameParameter_DefersValue#00.verified.yaml
@@ -0,0 +1,21 @@
+---
+apiVersion: "networking.k8s.io/v1"
+kind: "Ingress"
+metadata:
+ name: "public"
+spec:
+ rules:
+ - http:
+ paths:
+ - backend:
+ service:
+ name: "myapi-service"
+ port:
+ name: "http"
+ pathType: "Prefix"
+ path: "/api"
+ host: "{{ .Values.parameters.public.hostname }}"
+ tls:
+ - secretName: "public-tls"
+ hosts:
+ - "{{ .Values.parameters.public.hostname }}"
diff --git a/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_WithRuntimeOnlyHostnameParameter_DefersValue#01.verified.yaml b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_WithRuntimeOnlyHostnameParameter_DefersValue#01.verified.yaml
new file mode 100644
index 00000000000..6d621975b1f
--- /dev/null
+++ b/tests/Aspire.Hosting.Kubernetes.Tests/Snapshots/KubernetesIngressTests.AddIngress_WithRuntimeOnlyHostnameParameter_DefersValue#01.verified.yaml
@@ -0,0 +1,5 @@
+parameters:
+ public:
+ hostname: ""
+secrets: {}
+config: {}