diff --git a/pkg/i2gw/providers/ingressnginx/annotations.go b/pkg/i2gw/providers/ingressnginx/annotations.go index 68eb8b6d8..aac928a8f 100644 --- a/pkg/i2gw/providers/ingressnginx/annotations.go +++ b/pkg/i2gw/providers/ingressnginx/annotations.go @@ -36,4 +36,11 @@ const ( // SSL Redirect annotation SSLRedirectAnnotation = "nginx.ingress.kubernetes.io/ssl-redirect" + + // Additional redirect annotations + ForceSSLRedirectAnnotation = "nginx.ingress.kubernetes.io/force-ssl-redirect" + PermanentRedirectAnnotation = "nginx.ingress.kubernetes.io/permanent-redirect" + PermanentRedirectCodeAnnotation = "nginx.ingress.kubernetes.io/permanent-redirect-code" + TemporalRedirectAnnotation = "nginx.ingress.kubernetes.io/temporal-redirect" + FromToWWWRedirectAnnotation = "nginx.ingress.kubernetes.io/from-to-www-redirect" ) diff --git a/pkg/i2gw/providers/ingressnginx/converter.go b/pkg/i2gw/providers/ingressnginx/converter.go index 7de9a78cf..62a2bd385 100644 --- a/pkg/i2gw/providers/ingressnginx/converter.go +++ b/pkg/i2gw/providers/ingressnginx/converter.go @@ -34,6 +34,7 @@ func newResourcesToIRConverter() *resourcesToIRConverter { featureParsers: []i2gw.FeatureParser{ canaryFeature, headerModifierFeature, + redirectFeature, }, } } diff --git a/pkg/i2gw/providers/ingressnginx/redirect.go b/pkg/i2gw/providers/ingressnginx/redirect.go index 3e58bf194..3b1aa04d8 100644 --- a/pkg/i2gw/providers/ingressnginx/redirect.go +++ b/pkg/i2gw/providers/ingressnginx/redirect.go @@ -18,10 +18,13 @@ package ingressnginx import ( "fmt" + "net/url" "strconv" - emitterir "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/emitter_intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/notifications" providerir "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/provider_intermediate" + emitterir "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/emitter_intermediate" + networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" @@ -30,14 +33,195 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) +// redirectFeature parses redirect annotations (permanent-redirect, temporal-redirect) +// and applies them to HTTPRoute rules. +func redirectFeature(_ []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *providerir.ProviderIR) field.ErrorList { + var errs field.ErrorList + + for _, httpRouteContext := range ir.HTTPRoutes { + for i := range httpRouteContext.HTTPRoute.Spec.Rules { + if i >= len(httpRouteContext.RuleBackendSources) { + continue + } + sources := httpRouteContext.RuleBackendSources[i] + + ingress := getNonCanaryIngress(sources) + if ingress == nil { + continue + } + + redirectFilter, parseErrs := parseRedirectAnnotations(ingress, &httpRouteContext.HTTPRoute) + errs = append(errs, parseErrs...) + + if redirectFilter != nil { + // Add the redirect filter to the rule + httpRouteContext.HTTPRoute.Spec.Rules[i].Filters = append( + httpRouteContext.HTTPRoute.Spec.Rules[i].Filters, + *redirectFilter, + ) + notify(notifications.InfoNotification, fmt.Sprintf("Applied redirect to rule %d of route %s/%s", i, httpRouteContext.HTTPRoute.Namespace, httpRouteContext.HTTPRoute.Name), &httpRouteContext.HTTPRoute) + } + + // Warn about unsupported redirect annotations + warnUnsupportedRedirectAnnotations(ingress, &httpRouteContext.HTTPRoute) + } + } + + if len(errs) > 0 { + return errs + } + return nil +} + +// parseRedirectAnnotations parses permanent-redirect and temporal-redirect annotations +func parseRedirectAnnotations(ingress *networkingv1.Ingress, httpRoute *gatewayv1.HTTPRoute) (*gatewayv1.HTTPRouteFilter, field.ErrorList) { + var errs field.ErrorList + + // Check for permanent-redirect first (takes precedence) + if redirectURL, ok := ingress.Annotations[PermanentRedirectAnnotation]; ok && redirectURL != "" { + statusCode := 301 // Default for permanent redirect + + // Check for custom status code + if codeStr, ok := ingress.Annotations[PermanentRedirectCodeAnnotation]; ok && codeStr != "" { + code, err := strconv.Atoi(codeStr) + if err != nil { + errs = append(errs, field.Invalid( + field.NewPath("ingress", ingress.Namespace, ingress.Name, "metadata", "annotations", PermanentRedirectCodeAnnotation), + codeStr, + fmt.Sprintf("invalid redirect code: %v", err), + )) + } else if code < 300 || code > 399 { + errs = append(errs, field.Invalid( + field.NewPath("ingress", ingress.Namespace, ingress.Name, "metadata", "annotations", PermanentRedirectCodeAnnotation), + codeStr, + "redirect code must be between 300 and 399", + )) + } else { + statusCode = code + } + } + + // Warn if query string or fragment will be ignored + if hasQueryOrFragment(redirectURL) { + notify(notifications.WarningNotification, fmt.Sprintf("Ingress %s/%s: query string and fragment in redirect URL will be ignored (Gateway API limitation)", ingress.Namespace, ingress.Name), httpRoute) + } + + filter, parseErr := createRedirectFilter(redirectURL, statusCode) + if parseErr != nil { + errs = append(errs, field.Invalid( + field.NewPath("ingress", ingress.Namespace, ingress.Name, "metadata", "annotations", PermanentRedirectAnnotation), + redirectURL, + fmt.Sprintf("invalid redirect URL: %v", parseErr), + )) + return nil, errs + } + + notify(notifications.InfoNotification, fmt.Sprintf("Ingress %s/%s: parsed permanent-redirect=%s with code %d", ingress.Namespace, ingress.Name, redirectURL, statusCode), httpRoute) + return filter, errs + } + + // Check for temporal-redirect + if redirectURL, ok := ingress.Annotations[TemporalRedirectAnnotation]; ok && redirectURL != "" { + statusCode := 302 // Default for temporal redirect + + // Warn if query string or fragment will be ignored + if hasQueryOrFragment(redirectURL) { + notify(notifications.WarningNotification, fmt.Sprintf("Ingress %s/%s: query string and fragment in redirect URL will be ignored (Gateway API limitation)", ingress.Namespace, ingress.Name), httpRoute) + } + + filter, parseErr := createRedirectFilter(redirectURL, statusCode) + if parseErr != nil { + errs = append(errs, field.Invalid( + field.NewPath("ingress", ingress.Namespace, ingress.Name, "metadata", "annotations", TemporalRedirectAnnotation), + redirectURL, + fmt.Sprintf("invalid redirect URL: %v", parseErr), + )) + return nil, errs + } + + notify(notifications.InfoNotification, fmt.Sprintf("Ingress %s/%s: parsed temporal-redirect=%s with code %d", ingress.Namespace, ingress.Name, redirectURL, statusCode), httpRoute) + return filter, errs + } + + return nil, errs +} + +// createRedirectFilter creates an HTTPRouteFilter for a redirect URL +func createRedirectFilter(redirectURL string, statusCode int) (*gatewayv1.HTTPRouteFilter, error) { + parsedURL, err := url.Parse(redirectURL) + if err != nil { + return nil, err + } + + filter := &gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: &gatewayv1.HTTPRequestRedirectFilter{ + StatusCode: ptr.To(statusCode), + }, + } + + // Set scheme if present + if parsedURL.Scheme != "" { + filter.RequestRedirect.Scheme = ptr.To(parsedURL.Scheme) + } + + // Set hostname if present + if parsedURL.Hostname() != "" { + hostname := gatewayv1.PreciseHostname(parsedURL.Hostname()) + filter.RequestRedirect.Hostname = &hostname + } + + // Set port if present + if parsedURL.Port() != "" { + port, err := strconv.Atoi(parsedURL.Port()) + if err != nil { + return nil, fmt.Errorf("invalid port: %v", err) + } + if port < 1 || port > 65535 { + return nil, fmt.Errorf("port must be between 1 and 65535, got %d", port) + } + portNum := gatewayv1.PortNumber(port) + filter.RequestRedirect.Port = &portNum + } + + // Set path if present (excluding root path which is the default) + if parsedURL.Path != "" && parsedURL.Path != "/" { + filter.RequestRedirect.Path = &gatewayv1.HTTPPathModifier{ + Type: gatewayv1.FullPathHTTPPathModifier, + ReplaceFullPath: ptr.To(parsedURL.Path), + } + } + + return filter, nil +} + +// hasQueryOrFragment checks if a URL contains query string or fragment +func hasQueryOrFragment(redirectURL string) bool { + parsedURL, err := url.Parse(redirectURL) + if err != nil { + return false + } + return parsedURL.RawQuery != "" || parsedURL.Fragment != "" +} + +// warnUnsupportedRedirectAnnotations logs warnings for redirect annotations that +// cannot be directly translated to Gateway API +func warnUnsupportedRedirectAnnotations(ingress *networkingv1.Ingress, httpRoute *gatewayv1.HTTPRoute) { + if _, ok := ingress.Annotations[FromToWWWRedirectAnnotation]; ok { + notify(notifications.WarningNotification, fmt.Sprintf("Ingress %s/%s: from-to-www-redirect is not directly supported in Gateway API (requires multiple routes)", ingress.Namespace, ingress.Name), httpRoute) + } +} + // Ingress NGINX has some quirky behaviors around SSL redirect. // The formula we follow is that if an ingress has certs configured, and it does not have the // "nginx.ingress.kubernetes.io/ssl-redirect" annotation set to "false" (or "0", etc), then we // enable SSL redirect for that host. +// Also supports force-ssl-redirect which enables SSL redirect even without TLS configuration. func addDefaultSSLRedirect(pir *providerir.ProviderIR, eir *emitterir.EmitterIR) field.ErrorList { for key, httpRouteContext := range pir.HTTPRoutes { hasSecrets := false enableRedirect := true + forceRedirect := false for _, sources := range httpRouteContext.RuleBackendSources { ingress := getNonCanaryIngress(sources) @@ -57,14 +241,30 @@ func addDefaultSSLRedirect(pir *providerir.ProviderIR, eir *emitterir.EmitterIR) return field.ErrorList{field.Invalid( field.NewPath("ingress", ingress.Namespace, ingress.Name, "metadata", "annotations"), ingress.Annotations, - fmt.Sprintf("failed to parse canary configuration: %v", err), + fmt.Sprintf("failed to parse ssl-redirect annotation: %v", err), )} } enableRedirect = parsed } + + // Check the force-ssl-redirect annotation. + if val, ok := ingress.Annotations[ForceSSLRedirectAnnotation]; ok { + parsed, err := strconv.ParseBool(val) + if err != nil { + return field.ErrorList{field.Invalid( + field.NewPath("ingress", ingress.Namespace, ingress.Name, "metadata", "annotations"), + ingress.Annotations, + fmt.Sprintf("failed to parse force-ssl-redirect annotation: %v", err), + )} + } + forceRedirect = parsed + } } - if !(hasSecrets && enableRedirect) { + // Enable SSL redirect if: + // 1. Has TLS secrets and ssl-redirect is not disabled, OR + // 2. force-ssl-redirect is true + if !((hasSecrets && enableRedirect) || forceRedirect) { continue } diff --git a/pkg/i2gw/providers/ingressnginx/redirect_feature_test.go b/pkg/i2gw/providers/ingressnginx/redirect_feature_test.go new file mode 100644 index 000000000..840b7893f --- /dev/null +++ b/pkg/i2gw/providers/ingressnginx/redirect_feature_test.go @@ -0,0 +1,448 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ingressnginx + +import ( + "testing" + + providerir "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/provider_intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestCreateRedirectFilter(t *testing.T) { + testCases := []struct { + name string + url string + statusCode int + expectedScheme *string + expectedHost *gatewayv1.PreciseHostname + expectedPort *gatewayv1.PortNumber + expectedPath *string + expectError bool + }{ + { + name: "full URL with path", + url: "https://example.com/new-path", + statusCode: 301, + expectedScheme: ptr.To("https"), + expectedHost: ptr.To(gatewayv1.PreciseHostname("example.com")), + expectedPath: ptr.To("/new-path"), + expectError: false, + }, + { + name: "URL with port", + url: "https://example.com:8443", + statusCode: 302, + expectedScheme: ptr.To("https"), + expectedHost: ptr.To(gatewayv1.PreciseHostname("example.com")), + expectedPort: ptr.To(gatewayv1.PortNumber(8443)), + expectError: false, + }, + { + name: "simple URL", + url: "https://newsite.com", + statusCode: 301, + expectedScheme: ptr.To("https"), + expectedHost: ptr.To(gatewayv1.PreciseHostname("newsite.com")), + expectError: false, + }, + { + name: "URL with only path", + url: "/new-location", + statusCode: 302, + expectedPath: ptr.To("/new-location"), + expectError: false, + }, + { + name: "invalid port - out of range", + url: "https://example.com:99999", + statusCode: 301, + expectError: true, + }, + { + name: "URL with query string ignored", + url: "https://example.com/path?foo=bar", + statusCode: 301, + expectedScheme: ptr.To("https"), + expectedHost: ptr.To(gatewayv1.PreciseHostname("example.com")), + expectedPath: ptr.To("/path"), + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + filter, err := createRedirectFilter(tc.url, tc.statusCode) + if tc.expectError { + if err == nil { + t.Fatalf("Expected error but got none") + } + return + } + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if filter.Type != gatewayv1.HTTPRouteFilterRequestRedirect { + t.Errorf("Expected filter type RequestRedirect, got %s", filter.Type) + } + + redirect := filter.RequestRedirect + if redirect == nil { + t.Fatalf("Expected RequestRedirect to be set") + } + + if *redirect.StatusCode != tc.statusCode { + t.Errorf("Expected status code %d, got %d", tc.statusCode, *redirect.StatusCode) + } + + if tc.expectedScheme != nil { + if redirect.Scheme == nil || *redirect.Scheme != *tc.expectedScheme { + t.Errorf("Expected scheme %s, got %v", *tc.expectedScheme, redirect.Scheme) + } + } + + if tc.expectedHost != nil { + if redirect.Hostname == nil || *redirect.Hostname != *tc.expectedHost { + t.Errorf("Expected hostname %s, got %v", *tc.expectedHost, redirect.Hostname) + } + } + + if tc.expectedPort != nil { + if redirect.Port == nil || *redirect.Port != *tc.expectedPort { + t.Errorf("Expected port %d, got %v", *tc.expectedPort, redirect.Port) + } + } + + if tc.expectedPath != nil { + if redirect.Path == nil || redirect.Path.ReplaceFullPath == nil || *redirect.Path.ReplaceFullPath != *tc.expectedPath { + var actual string + if redirect.Path != nil && redirect.Path.ReplaceFullPath != nil { + actual = *redirect.Path.ReplaceFullPath + } + t.Errorf("Expected path %s, got %s", *tc.expectedPath, actual) + } + } + }) + } +} + +func TestRedirectFeature(t *testing.T) { + testCases := []struct { + name string + ingress networkingv1.Ingress + expectedFilterCount int + expectedStatusCode int + expectedScheme *string + expectedHostname *gatewayv1.PreciseHostname + expectError bool + }{ + { + name: "permanent-redirect", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-permanent", + Namespace: "default", + Annotations: map[string]string{ + PermanentRedirectAnnotation: "https://newsite.com", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedFilterCount: 1, + expectedStatusCode: 301, + expectedScheme: ptr.To("https"), + expectedHostname: ptr.To(gatewayv1.PreciseHostname("newsite.com")), + expectError: false, + }, + { + name: "permanent-redirect with custom code", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-permanent-code", + Namespace: "default", + Annotations: map[string]string{ + PermanentRedirectAnnotation: "https://newsite.com", + PermanentRedirectCodeAnnotation: "308", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedFilterCount: 1, + expectedStatusCode: 308, + expectedScheme: ptr.To("https"), + expectedHostname: ptr.To(gatewayv1.PreciseHostname("newsite.com")), + expectError: false, + }, + { + name: "temporal-redirect", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-temporal", + Namespace: "default", + Annotations: map[string]string{ + TemporalRedirectAnnotation: "https://maintenance.example.com", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedFilterCount: 1, + expectedStatusCode: 302, + expectedScheme: ptr.To("https"), + expectedHostname: ptr.To(gatewayv1.PreciseHostname("maintenance.example.com")), + expectError: false, + }, + { + name: "no redirect annotations", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-no-redirect", + Namespace: "default", + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedFilterCount: 0, + expectError: false, + }, + { + name: "invalid redirect code", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-invalid-code", + Namespace: "default", + Annotations: map[string]string{ + PermanentRedirectAnnotation: "https://newsite.com", + PermanentRedirectCodeAnnotation: "500", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "test-service", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedFilterCount: 0, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ir := providerir.ProviderIR{ + HTTPRoutes: make(map[types.NamespacedName]providerir.HTTPRouteContext), + } + + key := types.NamespacedName{Namespace: tc.ingress.Namespace, Name: common.RouteName(tc.ingress.Name, "example.com")} + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: tc.ingress.Namespace, + Name: key.Name, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: ptr.To("/"), + }, + }, + }, + }, + }, + }, + } + ir.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{ + { + {Ingress: &tc.ingress}, + }, + }, + } + + errs := redirectFeature([]networkingv1.Ingress{tc.ingress}, nil, &ir) + if tc.expectError { + if len(errs) == 0 { + t.Fatalf("Expected errors but got none") + } + return + } + if len(errs) > 0 { + t.Fatalf("Expected no errors, got %v", errs) + } + + result := ir.HTTPRoutes[key] + rules := result.HTTPRoute.Spec.Rules + if len(rules) != 1 { + t.Fatalf("Expected 1 rule, got %d", len(rules)) + } + + filters := rules[0].Filters + if len(filters) != tc.expectedFilterCount { + t.Fatalf("Expected %d filters, got %d", tc.expectedFilterCount, len(filters)) + } + + if tc.expectedFilterCount == 0 { + return + } + + redirect := filters[0].RequestRedirect + if redirect == nil { + t.Fatalf("Expected RequestRedirect to be set") + } + + if *redirect.StatusCode != tc.expectedStatusCode { + t.Errorf("Expected status code %d, got %d", tc.expectedStatusCode, *redirect.StatusCode) + } + + if tc.expectedScheme != nil { + if redirect.Scheme == nil || *redirect.Scheme != *tc.expectedScheme { + t.Errorf("Expected scheme %s, got %v", *tc.expectedScheme, redirect.Scheme) + } + } + + if tc.expectedHostname != nil { + if redirect.Hostname == nil || *redirect.Hostname != *tc.expectedHostname { + t.Errorf("Expected hostname %s, got %v", *tc.expectedHostname, redirect.Hostname) + } + } + }) + } +} diff --git a/pkg/i2gw/providers/ingressnginx/redirect_test.go b/pkg/i2gw/providers/ingressnginx/redirect_test.go index d5300c8f9..873bfe7f0 100644 --- a/pkg/i2gw/providers/ingressnginx/redirect_test.go +++ b/pkg/i2gw/providers/ingressnginx/redirect_test.go @@ -199,3 +199,64 @@ func TestAddDefaultSSLRedirect_noTLS(t *testing.T) { t.Fatalf("expected original route parentRef port to remain nil, got %#v", origCtx.Spec.ParentRefs[0].Port) } } + +func TestAddDefaultSSLRedirect_forceSSLRedirect(t *testing.T) { + key := types.NamespacedName{Namespace: "default", Name: "route"} + parentRefs := []gatewayv1.ParentReference{{Name: gatewayv1.ObjectName("gw")}} + + // No TLS configured, but force-ssl-redirect is true + ing := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: key.Namespace, + Name: "ing", + Annotations: map[string]string{ + ForceSSLRedirectAnnotation: "true", + }, + }, + Spec: networkingv1.IngressSpec{ + // No TLS + }, + } + + route := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Namespace: key.Namespace, Name: key.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: append([]gatewayv1.ParentReference(nil), parentRefs...), + }, + Hostnames: []gatewayv1.Hostname{"example.com"}, + }, + } + + pIR := providerir.ProviderIR{HTTPRoutes: map[types.NamespacedName]providerir.HTTPRouteContext{}} + pIR.HTTPRoutes[key] = providerir.HTTPRouteContext{ + HTTPRoute: route, + RuleBackendSources: [][]providerir.BackendSource{{ + {Ingress: &ing}, + }}, + } + + eIR := emitterir.EmitterIR{HTTPRoutes: map[types.NamespacedName]emitterir.HTTPRouteContext{}} + eIR.HTTPRoutes[key] = emitterir.HTTPRouteContext{HTTPRoute: route} + + addDefaultSSLRedirect(&pIR, &eIR) + + // Should create redirect route even without TLS because force-ssl-redirect is true + redirectKey := types.NamespacedName{Namespace: key.Namespace, Name: key.Name + "-ssl-redirect"} + redirectCtx, ok := eIR.HTTPRoutes[redirectKey] + if !ok { + t.Fatalf("expected redirect route %v to be added with force-ssl-redirect", redirectKey) + } + + if len(redirectCtx.Spec.ParentRefs) != 1 || redirectCtx.Spec.ParentRefs[0].Port == nil || *redirectCtx.Spec.ParentRefs[0].Port != 80 { + t.Fatalf("expected redirect route parentRef port 80, got %#v", redirectCtx.Spec.ParentRefs) + } + + f := redirectCtx.Spec.Rules[0].Filters[0] + if f.Type != gatewayv1.HTTPRouteFilterRequestRedirect || f.RequestRedirect == nil { + t.Fatalf("expected RequestRedirect filter, got %#v", f) + } + if f.RequestRedirect.Scheme == nil || *f.RequestRedirect.Scheme != "https" { + t.Fatalf("expected scheme https, got %#v", f.RequestRedirect.Scheme) + } +}