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: {}