diff --git a/README.md b/README.md index 5d03e4ebf..1aed5925d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ API. * [istio](pkg/i2gw/providers/istio/README.md) * [gce](pkg/i2gw/providers/gce/README.md) * [kong](pkg/i2gw/providers/kong/README.md) +* [nginx](pkg/i2gw/providers/nginx/README.md) * [openapi](pkg/i2gw/providers/openapi3/README.md) If your provider, or a specific feature, is not currently supported, please open diff --git a/cmd/print.go b/cmd/print.go index 0bb802bce..896770f34 100644 --- a/cmd/print.go +++ b/cmd/print.go @@ -38,6 +38,7 @@ import ( _ "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/ingressnginx" _ "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/istio" _ "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/kong" + _ "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/nginx" _ "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/openapi3" // Call init for notifications @@ -146,6 +147,21 @@ func (pr *PrintRunner) outputResult(gatewayResources []i2gw.GatewayResources) { } } + for _, r := range gatewayResources { + resourceCount += len(r.GRPCRoutes) + for _, grpcRoute := range r.GRPCRoutes { + grpcRoute := grpcRoute + if grpcRoute.Annotations == nil { + grpcRoute.Annotations = make(map[string]string) + } + grpcRoute.Annotations[i2gw.GeneratorAnnotationKey] = fmt.Sprintf("ingress2gateway-%s", i2gw.Version) + err := pr.resourcePrinter.PrintObj(&grpcRoute, os.Stdout) + if err != nil { + fmt.Printf("# Error printing %s GRPCRoute: %v\n", grpcRoute.Name, err) + } + } + } + for _, r := range gatewayResources { resourceCount += len(r.TLSRoutes) for _, tlsRoute := range r.TLSRoutes { @@ -191,6 +207,21 @@ func (pr *PrintRunner) outputResult(gatewayResources []i2gw.GatewayResources) { } } + for _, r := range gatewayResources { + resourceCount += len(r.BackendTLSPolicies) + for _, backendTLSPolicy := range r.BackendTLSPolicies { + backendTLSPolicy := backendTLSPolicy + if backendTLSPolicy.Annotations == nil { + backendTLSPolicy.Annotations = make(map[string]string) + } + backendTLSPolicy.Annotations[i2gw.GeneratorAnnotationKey] = fmt.Sprintf("ingress2gateway-%s", i2gw.Version) + err := pr.resourcePrinter.PrintObj(&backendTLSPolicy, os.Stdout) + if err != nil { + fmt.Printf("# Error printing %s BackendTLSPolicy: %v\n", backendTLSPolicy.Name, err) + } + } + } + for _, r := range gatewayResources { resourceCount += len(r.ReferenceGrants) for _, referenceGrant := range r.ReferenceGrants { @@ -277,7 +308,7 @@ func newPrintCommand() *cobra.Command { var printFlags genericclioptions.JSONYamlPrintFlags allowedFormats := printFlags.AllowedFormats() - // printCmd represents the print command. It prints HTTPRoutes and Gateways + // printCmd represents the print command. It prints Gateway API resources // generated from Ingress resources. var cmd = &cobra.Command{ Use: "print", diff --git a/go.mod b/go.mod index 7a6834e60..2729a0184 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/mattn/go-runewidth v0.0.15 // indirect github.com/moby/term v0.0.0-20221205130635-1aeaba878587 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/onsi/gomega v1.33.0 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.2.0 // indirect diff --git a/go.sum b/go.sum index 30922486d..539979dba 100644 --- a/go.sum +++ b/go.sum @@ -98,8 +98,8 @@ github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6 github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= -github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= -github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= +github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= +github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxjX3wY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= diff --git a/pkg/i2gw/intermediate/intermediate_representation.go b/pkg/i2gw/intermediate/intermediate_representation.go index 9613c9503..9928ec13a 100644 --- a/pkg/i2gw/intermediate/intermediate_representation.go +++ b/pkg/i2gw/intermediate/intermediate_representation.go @@ -20,6 +20,7 @@ import ( "k8s.io/apimachinery/pkg/types" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatewayv1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" ) @@ -36,8 +37,10 @@ type IR struct { TLSRoutes map[types.NamespacedName]gatewayv1alpha2.TLSRoute TCPRoutes map[types.NamespacedName]gatewayv1alpha2.TCPRoute UDPRoutes map[types.NamespacedName]gatewayv1alpha2.UDPRoute + GRPCRoutes map[types.NamespacedName]gatewayv1.GRPCRoute - ReferenceGrants map[types.NamespacedName]gatewayv1beta1.ReferenceGrant + BackendTLSPolicies map[types.NamespacedName]gatewayv1alpha3.BackendTLSPolicy + ReferenceGrants map[types.NamespacedName]gatewayv1beta1.ReferenceGrant } // GatewayContext contains the Gateway-API Gateway object and GatewayIR, which @@ -90,4 +93,5 @@ type ProviderSpecificServiceIR struct { Istio *IstioServiceIR Kong *KongServiceIR Openapi3 *Openapi3ServiceIR + Nginx *NginxServiceIR } diff --git a/pkg/i2gw/intermediate/provider_nginx.go b/pkg/i2gw/intermediate/provider_nginx.go new file mode 100644 index 000000000..a391e6541 --- /dev/null +++ b/pkg/i2gw/intermediate/provider_nginx.go @@ -0,0 +1,21 @@ +/* +Copyright 2024 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 intermediate + +type NginxGatewayIR struct{} +type NginxHTTPRouteIR struct{} +type NginxServiceIR struct{} diff --git a/pkg/i2gw/intermediate/utils.go b/pkg/i2gw/intermediate/utils.go index bcd94e223..1c1e5961e 100644 --- a/pkg/i2gw/intermediate/utils.go +++ b/pkg/i2gw/intermediate/utils.go @@ -24,6 +24,7 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatewayv1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" ) @@ -38,14 +39,16 @@ import ( // This behavior is likely to change after https://github.com/kubernetes-sigs/gateway-api/pull/1863 takes place. func MergeIRs(irs ...IR) (IR, field.ErrorList) { mergedIRs := IR{ - Gateways: make(map[types.NamespacedName]GatewayContext), - GatewayClasses: make(map[types.NamespacedName]gatewayv1.GatewayClass), - HTTPRoutes: make(map[types.NamespacedName]HTTPRouteContext), - Services: make(map[types.NamespacedName]ProviderSpecificServiceIR), - TLSRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TLSRoute), - TCPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TCPRoute), - UDPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.UDPRoute), - ReferenceGrants: make(map[types.NamespacedName]gatewayv1beta1.ReferenceGrant), + Gateways: make(map[types.NamespacedName]GatewayContext), + GatewayClasses: make(map[types.NamespacedName]gatewayv1.GatewayClass), + HTTPRoutes: make(map[types.NamespacedName]HTTPRouteContext), + Services: make(map[types.NamespacedName]ProviderSpecificServiceIR), + TLSRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TLSRoute), + TCPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TCPRoute), + UDPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.UDPRoute), + GRPCRoutes: make(map[types.NamespacedName]gatewayv1.GRPCRoute), + BackendTLSPolicies: make(map[types.NamespacedName]gatewayv1alpha3.BackendTLSPolicy), + ReferenceGrants: make(map[types.NamespacedName]gatewayv1beta1.ReferenceGrant), } var errs field.ErrorList mergedIRs.Gateways, errs = mergeGatewayContexts(irs) @@ -60,6 +63,8 @@ func MergeIRs(irs ...IR) (IR, field.ErrorList) { maps.Copy(mergedIRs.TLSRoutes, gr.TLSRoutes) maps.Copy(mergedIRs.TCPRoutes, gr.TCPRoutes) maps.Copy(mergedIRs.UDPRoutes, gr.UDPRoutes) + maps.Copy(mergedIRs.GRPCRoutes, gr.GRPCRoutes) + maps.Copy(mergedIRs.BackendTLSPolicies, gr.BackendTLSPolicies) maps.Copy(mergedIRs.ReferenceGrants, gr.ReferenceGrants) } return mergedIRs, errs diff --git a/pkg/i2gw/provider.go b/pkg/i2gw/provider.go index 9c574ce31..2be382120 100644 --- a/pkg/i2gw/provider.go +++ b/pkg/i2gw/provider.go @@ -28,6 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatewayv1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" ) @@ -104,11 +105,13 @@ type GatewayResources struct { GatewayClasses map[types.NamespacedName]gatewayv1.GatewayClass HTTPRoutes map[types.NamespacedName]gatewayv1.HTTPRoute + GRPCRoutes map[types.NamespacedName]gatewayv1.GRPCRoute TLSRoutes map[types.NamespacedName]gatewayv1alpha2.TLSRoute TCPRoutes map[types.NamespacedName]gatewayv1alpha2.TCPRoute UDPRoutes map[types.NamespacedName]gatewayv1alpha2.UDPRoute - ReferenceGrants map[types.NamespacedName]gatewayv1beta1.ReferenceGrant + BackendTLSPolicies map[types.NamespacedName]gatewayv1alpha3.BackendTLSPolicy + ReferenceGrants map[types.NamespacedName]gatewayv1beta1.ReferenceGrant GatewayExtensions []unstructured.Unstructured } diff --git a/pkg/i2gw/providers/common/converter.go b/pkg/i2gw/providers/common/converter.go index 785cc3bdf..337b410f0 100644 --- a/pkg/i2gw/providers/common/converter.go +++ b/pkg/i2gw/providers/common/converter.go @@ -30,6 +30,9 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatewayv1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" + gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" ) // ToIR converts the received ingresses to intermediate.IR without taking into @@ -66,8 +69,16 @@ func ToIR(ingresses []networkingv1.Ingress, servicePorts map[types.NamespacedNam } return intermediate.IR{ - Gateways: gatewayByKey, - HTTPRoutes: routeByKey, + Gateways: gatewayByKey, + HTTPRoutes: routeByKey, + Services: make(map[types.NamespacedName]intermediate.ProviderSpecificServiceIR), + GatewayClasses: make(map[types.NamespacedName]gatewayv1.GatewayClass), + TLSRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TLSRoute), + TCPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TCPRoute), + UDPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.UDPRoute), + GRPCRoutes: make(map[types.NamespacedName]gatewayv1.GRPCRoute), + BackendTLSPolicies: make(map[types.NamespacedName]gatewayv1alpha3.BackendTLSPolicy), + ReferenceGrants: make(map[types.NamespacedName]gatewayv1beta1.ReferenceGrant), }, nil } diff --git a/pkg/i2gw/providers/common/gateway_converter.go b/pkg/i2gw/providers/common/gateway_converter.go index 066c983b4..ea5e5738f 100644 --- a/pkg/i2gw/providers/common/gateway_converter.go +++ b/pkg/i2gw/providers/common/gateway_converter.go @@ -28,13 +28,15 @@ import ( // without taking into consideration any provider specific logic. func ToGatewayResources(ir intermediate.IR) (i2gw.GatewayResources, field.ErrorList) { gatewayResources := i2gw.GatewayResources{ - Gateways: make(map[types.NamespacedName]gatewayv1.Gateway), - HTTPRoutes: make(map[types.NamespacedName]gatewayv1.HTTPRoute), - GatewayClasses: ir.GatewayClasses, - TLSRoutes: ir.TLSRoutes, - TCPRoutes: ir.TCPRoutes, - UDPRoutes: ir.UDPRoutes, - ReferenceGrants: ir.ReferenceGrants, + Gateways: make(map[types.NamespacedName]gatewayv1.Gateway), + HTTPRoutes: make(map[types.NamespacedName]gatewayv1.HTTPRoute), + GatewayClasses: ir.GatewayClasses, + GRPCRoutes: ir.GRPCRoutes, + TLSRoutes: ir.TLSRoutes, + TCPRoutes: ir.TCPRoutes, + UDPRoutes: ir.UDPRoutes, + BackendTLSPolicies: ir.BackendTLSPolicies, + ReferenceGrants: ir.ReferenceGrants, } for key, gatewayContext := range ir.Gateways { gatewayResources.Gateways[key] = gatewayContext.Gateway diff --git a/pkg/i2gw/providers/common/utils.go b/pkg/i2gw/providers/common/utils.go index 23440dd1a..7d2cb355c 100644 --- a/pkg/i2gw/providers/common/utils.go +++ b/pkg/i2gw/providers/common/utils.go @@ -19,13 +19,17 @@ package common import ( "fmt" "regexp" + "strings" apiv1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" networkingv1beta1 "k8s.io/api/networking/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatewayv1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" ) func GetIngressClass(ingress networkingv1.Ingress) string { @@ -226,3 +230,153 @@ func removeBackendRefsDuplicates(backendRefs []gatewayv1.HTTPBackendRef) []gatew } return uniqueBackendRefs } + +// ParseGRPCServiceMethod parses gRPC service and method from HTTP path +func ParseGRPCServiceMethod(path string) (service, method string) { + path = strings.TrimPrefix(path, "/") + + parts := strings.SplitN(path, "/", 2) + if len(parts) >= 1 && parts[0] != "" { + service = parts[0] + } + if len(parts) >= 2 && parts[1] != "" { + method = parts[1] + } + + return service, method +} + +// GRPCFilterConversionResult holds the result of converting HTTP filters to gRPC filters +type GRPCFilterConversionResult struct { + GRPCFilters []gatewayv1.GRPCRouteFilter + UnsupportedTypes []gatewayv1.HTTPRouteFilterType +} + +// ConvertHTTPFiltersToGRPCFilters converts a list of HTTPRoute filters to GRPCRoute filters +// Returns both the converted filters and a list of unsupported filter types for notification +func ConvertHTTPFiltersToGRPCFilters(httpFilters []gatewayv1.HTTPRouteFilter) GRPCFilterConversionResult { + if len(httpFilters) == 0 { + return GRPCFilterConversionResult{ + GRPCFilters: []gatewayv1.GRPCRouteFilter{}, + UnsupportedTypes: []gatewayv1.HTTPRouteFilterType{}, + } + } + + var grpcFilters []gatewayv1.GRPCRouteFilter + var unsupportedTypes []gatewayv1.HTTPRouteFilterType + + for _, httpFilter := range httpFilters { + switch httpFilter.Type { + case gatewayv1.HTTPRouteFilterRequestHeaderModifier: + if httpFilter.RequestHeaderModifier != nil { + grpcFilter := gatewayv1.GRPCRouteFilter{ + Type: gatewayv1.GRPCRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: httpFilter.RequestHeaderModifier.Set, + Add: httpFilter.RequestHeaderModifier.Add, + Remove: httpFilter.RequestHeaderModifier.Remove, + }, + } + grpcFilters = append(grpcFilters, grpcFilter) + } + + case gatewayv1.HTTPRouteFilterResponseHeaderModifier: + if httpFilter.ResponseHeaderModifier != nil { + grpcFilter := gatewayv1.GRPCRouteFilter{ + Type: gatewayv1.GRPCRouteFilterResponseHeaderModifier, + ResponseHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: httpFilter.ResponseHeaderModifier.Set, + Add: httpFilter.ResponseHeaderModifier.Add, + Remove: httpFilter.ResponseHeaderModifier.Remove, + }, + } + grpcFilters = append(grpcFilters, grpcFilter) + } + + // These HTTP filter types are not applicable to gRPC + case gatewayv1.HTTPRouteFilterRequestRedirect: + unsupportedTypes = append(unsupportedTypes, httpFilter.Type) + case gatewayv1.HTTPRouteFilterURLRewrite: + unsupportedTypes = append(unsupportedTypes, httpFilter.Type) + case gatewayv1.HTTPRouteFilterRequestMirror: + unsupportedTypes = append(unsupportedTypes, httpFilter.Type) + case gatewayv1.HTTPRouteFilterExtensionRef: + unsupportedTypes = append(unsupportedTypes, httpFilter.Type) + default: + unsupportedTypes = append(unsupportedTypes, httpFilter.Type) + } + } + + // Ensure we return empty slices instead of nil + if grpcFilters == nil { + grpcFilters = []gatewayv1.GRPCRouteFilter{} + } + if unsupportedTypes == nil { + unsupportedTypes = []gatewayv1.HTTPRouteFilterType{} + } + + return GRPCFilterConversionResult{ + GRPCFilters: grpcFilters, + UnsupportedTypes: unsupportedTypes, + } +} + +// RemoveGRPCRulesFromHTTPRoute removes HTTPRoute rules that target gRPC services. +// When route rules are converted from HTTP to gRPC routes, this function cleans up the original +// HTTPRoute by removing backend references to those gRPC services, preventing duplicate routing. +func RemoveGRPCRulesFromHTTPRoute(httpRoute *gatewayv1.HTTPRoute, grpcServiceSet map[string]struct{}) []gatewayv1.HTTPRouteRule { + var remainingRules []gatewayv1.HTTPRouteRule + + for _, rule := range httpRoute.Spec.Rules { + var remainingBackendRefs []gatewayv1.HTTPBackendRef + + // Check each backend ref in the rule + for _, backendRef := range rule.BackendRefs { + serviceName := string(backendRef.BackendRef.BackendObjectReference.Name) + // Only keep backend refs that are NOT gRPC services + if _, isGRPCService := grpcServiceSet[serviceName]; !isGRPCService { + remainingBackendRefs = append(remainingBackendRefs, backendRef) + } + } + + // If any backend refs remain, keep the rule + if len(remainingBackendRefs) > 0 { + rule.BackendRefs = remainingBackendRefs + remainingRules = append(remainingRules, rule) + } + } + + return remainingRules +} + +// CreateBackendTLSPolicy creates a BackendTLSPolicy for the given service +func CreateBackendTLSPolicy(namespace, policyName, serviceName string) gatewayv1alpha3.BackendTLSPolicy { + + // TODO: Migrate BackendTLSPolicy from gatewayv1alpha3 to gatewayv1 for Gateway API 1.4 + // See: https://github.com/kubernetes-sigs/ingress2gateway/issues/236 + return gatewayv1alpha3.BackendTLSPolicy{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gatewayv1alpha3.GroupVersion.String(), + Kind: "BackendTLSPolicy", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: policyName, + Namespace: namespace, + }, + Spec: gatewayv1alpha3.BackendTLSPolicySpec{ + TargetRefs: []gatewayv1alpha2.LocalPolicyTargetReferenceWithSectionName{ + { + LocalPolicyTargetReference: gatewayv1alpha2.LocalPolicyTargetReference{ + Group: "", // Core group + Kind: "Service", + Name: gatewayv1.ObjectName(serviceName), + }, + }, + }, + Validation: gatewayv1alpha3.BackendTLSPolicyValidation{ + // Note: WellKnownCACertificates and Hostname fields are intentionally left empty + // These fields must be manually configured based on your backend service's TLS setup + }, + }, + } +} diff --git a/pkg/i2gw/providers/common/utils_test.go b/pkg/i2gw/providers/common/utils_test.go index c65b1e9e4..5d74f3db1 100644 --- a/pkg/i2gw/providers/common/utils_test.go +++ b/pkg/i2gw/providers/common/utils_test.go @@ -24,6 +24,8 @@ import ( networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" ) func TestGroupIngressPathsByMatchKey(t *testing.T) { @@ -578,3 +580,344 @@ func TestGroupServicePortsByPortName(t *testing.T) { require.Equal(t, expected, GroupServicePortsByPortName(services)) }) } + +func TestParseGRPCServiceMethod(t *testing.T) { + testCases := []struct { + name string + path string + expectedService string + expectedMethod string + }{ + { + name: "empty path", + path: "", + expectedService: "", + expectedMethod: "", + }, + { + name: "root path", + path: "/", + expectedService: "", + expectedMethod: "", + }, + { + name: "service only", + path: "/UserService", + expectedService: "UserService", + expectedMethod: "", + }, + { + name: "service and method", + path: "/UserService/GetUser", + expectedService: "UserService", + expectedMethod: "GetUser", + }, + { + name: "service and method with extra path", + path: "/UserService/GetUser/extra", + expectedService: "UserService", + expectedMethod: "GetUser/extra", + }, + { + name: "path without leading slash", + path: "UserService/GetUser", + expectedService: "UserService", + expectedMethod: "GetUser", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + service, method := ParseGRPCServiceMethod(tc.path) + require.Equal(t, tc.expectedService, service) + require.Equal(t, tc.expectedMethod, method) + }) + } +} + +func TestConvertHTTPFiltersToGRPCFilters(t *testing.T) { + testCases := []struct { + name string + httpFilters []gatewayv1.HTTPRouteFilter + expectedGRPCFilters []gatewayv1.GRPCRouteFilter + expectedUnsupported []gatewayv1.HTTPRouteFilterType + }{ + { + name: "empty filters", + httpFilters: []gatewayv1.HTTPRouteFilter{}, + expectedGRPCFilters: []gatewayv1.GRPCRouteFilter{}, + expectedUnsupported: []gatewayv1.HTTPRouteFilterType{}, + }, + { + name: "request header modifier", + httpFilters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "X-Custom-Header", Value: "test"}, + }, + Add: []gatewayv1.HTTPHeader{ + {Name: "X-Additional", Value: "header"}, + }, + Remove: []string{"X-Remove"}, + }, + }, + }, + expectedGRPCFilters: []gatewayv1.GRPCRouteFilter{ + { + Type: gatewayv1.GRPCRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "X-Custom-Header", Value: "test"}, + }, + Add: []gatewayv1.HTTPHeader{ + {Name: "X-Additional", Value: "header"}, + }, + Remove: []string{"X-Remove"}, + }, + }, + }, + expectedUnsupported: []gatewayv1.HTTPRouteFilterType{}, + }, + { + name: "response header modifier", + httpFilters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterResponseHeaderModifier, + ResponseHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "X-Response-Header", Value: "value"}, + }, + }, + }, + }, + expectedGRPCFilters: []gatewayv1.GRPCRouteFilter{ + { + Type: gatewayv1.GRPCRouteFilterResponseHeaderModifier, + ResponseHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "X-Response-Header", Value: "value"}, + }, + }, + }, + }, + expectedUnsupported: []gatewayv1.HTTPRouteFilterType{}, + }, + { + name: "unsupported filters are tracked", + httpFilters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: &gatewayv1.HTTPRequestRedirectFilter{ + Hostname: PtrTo(gatewayv1.PreciseHostname("example.com")), + }, + }, + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "X-Custom", Value: "test"}, + }, + }, + }, + }, + expectedGRPCFilters: []gatewayv1.GRPCRouteFilter{ + { + Type: gatewayv1.GRPCRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "X-Custom", Value: "test"}, + }, + }, + }, + }, + expectedUnsupported: []gatewayv1.HTTPRouteFilterType{ + gatewayv1.HTTPRouteFilterRequestRedirect, + }, + }, + { + name: "multiple unsupported filters", + httpFilters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestRedirect, + }, + { + Type: gatewayv1.HTTPRouteFilterURLRewrite, + }, + { + Type: gatewayv1.HTTPRouteFilterRequestMirror, + }, + }, + expectedGRPCFilters: []gatewayv1.GRPCRouteFilter{}, + expectedUnsupported: []gatewayv1.HTTPRouteFilterType{ + gatewayv1.HTTPRouteFilterRequestRedirect, + gatewayv1.HTTPRouteFilterURLRewrite, + gatewayv1.HTTPRouteFilterRequestMirror, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + result := ConvertHTTPFiltersToGRPCFilters(tc.httpFilters) + require.Equal(t, tc.expectedGRPCFilters, result.GRPCFilters) + require.Equal(t, tc.expectedUnsupported, result.UnsupportedTypes) + }) + } +} + +func TestRemoveGRPCRulesFromHTTPRoute(t *testing.T) { + testCases := []struct { + name string + httpRoute *gatewayv1.HTTPRoute + grpcServiceSet map[string]struct{} + expectedRules int + }{ + { + name: "no gRPC services - all rules remain", + httpRoute: &gatewayv1.HTTPRoute{ + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "http-service", + }, + }, + }, + }, + }, + }, + }, + }, + grpcServiceSet: map[string]struct{}{}, + expectedRules: 1, + }, + { + name: "remove gRPC service rules", + httpRoute: &gatewayv1.HTTPRoute{ + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "grpc-service", + }, + }, + }, + }, + }, + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "http-service", + }, + }, + }, + }, + }, + }, + }, + }, + grpcServiceSet: map[string]struct{}{"grpc-service": {}}, + expectedRules: 1, + }, + { + name: "mixed backend refs in same rule", + httpRoute: &gatewayv1.HTTPRoute{ + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "grpc-service", + }, + }, + }, + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "http-service", + }, + }, + }, + }, + }, + }, + }, + }, + grpcServiceSet: map[string]struct{}{"grpc-service": {}}, + expectedRules: 1, // Rule remains but with only HTTP backend refs + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + result := RemoveGRPCRulesFromHTTPRoute(tc.httpRoute, tc.grpcServiceSet) + require.Len(t, result, tc.expectedRules) + + // For the mixed backend refs test, verify that only non-gRPC services remain + if tc.name == "mixed backend refs in same rule" && len(result) > 0 { + require.Len(t, result[0].BackendRefs, 1) + require.Equal(t, gatewayv1.ObjectName("http-service"), result[0].BackendRefs[0].BackendRef.BackendObjectReference.Name) + } + }) + } +} + +func TestCreateBackendTLSPolicy(t *testing.T) { + testCases := []struct { + name string + namespace string + policyName string + serviceName string + }{ + { + name: "basic policy creation", + namespace: "default", + policyName: "test-ingress-ssl-service-backend-tls", + serviceName: "ssl-service", + }, + { + name: "different namespace", + namespace: "production", + policyName: "api-ingress-secure-api-backend-tls", + serviceName: "secure-api", + }, + { + name: "custom policy name", + namespace: "custom", + policyName: "my-custom-policy", + serviceName: "custom-service", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + policy := CreateBackendTLSPolicy(tc.namespace, tc.policyName, tc.serviceName) + + require.Equal(t, tc.policyName, policy.Name) + require.Equal(t, tc.namespace, policy.Namespace) + require.Equal(t, "BackendTLSPolicy", policy.Kind) + require.Equal(t, gatewayv1alpha3.GroupVersion.String(), policy.APIVersion) + + require.Len(t, policy.Spec.TargetRefs, 1) + require.Equal(t, gatewayv1.ObjectName(tc.serviceName), policy.Spec.TargetRefs[0].Name) + require.Equal(t, "", string(policy.Spec.TargetRefs[0].Group)) // Core group + require.Equal(t, "Service", string(policy.Spec.TargetRefs[0].Kind)) + }) + } +} diff --git a/pkg/i2gw/providers/nginx/Makefile b/pkg/i2gw/providers/nginx/Makefile new file mode 100644 index 000000000..f4c53491c --- /dev/null +++ b/pkg/i2gw/providers/nginx/Makefile @@ -0,0 +1,23 @@ +# Generate fixtures. +# This target assumes that the 'ingress2gateway' binary has already been built +# and is located at the root of the project. +.PHONY: generate-fixtures +generate-fixtures: ## Generate fixtures with notifications (for debugging) + @echo "Generating fixtures..." + @for dir in $(shell find fixtures -type d -name "input"); do \ + for file in $$(find $$dir -type f); do \ + echo "Processing $$file..."; \ + ../../../../ingress2gateway print --input-file $$file --providers nginx > $$(dirname $$(dirname $$file))/output/$$(basename $$file); \ + done; \ + done + +.PHONY: generate-clean-fixtures +generate-clean-fixtures: ## Generate fixtures without notifications (clean YAML only) + @echo "Generating clean fixtures..." + @for dir in $(shell find fixtures -type d -name "input"); do \ + for file in $$(find $$dir -type f); do \ + echo "Processing $$file..."; \ + ../../../../ingress2gateway print --input-file $$file --providers nginx | \ + awk '/^apiVersion:/ {found=1} found {print}' > $$(dirname $$(dirname $$file))/output/$$(basename $$file); \ + done; \ + done \ No newline at end of file diff --git a/pkg/i2gw/providers/nginx/README.md b/pkg/i2gw/providers/nginx/README.md new file mode 100644 index 000000000..21e9a51c2 --- /dev/null +++ b/pkg/i2gw/providers/nginx/README.md @@ -0,0 +1,78 @@ +# NGINX Ingress Controller Provider + +This provider converts [NGINX Ingress Controller](https://github.com/nginx/kubernetes-ingress) resources to Gateway API resources. + +**Note**: This provider is specifically for NGINX Ingress Controller, not the community [ingress-nginx](https://github.com/kubernetes/ingress-nginx) controller. If you're using the community ingress-nginx controller, please use the `ingress-nginx` provider instead. + +## Supported Resources + +* **Ingress** - Core Kubernetes Ingress resources with NGINX-specific annotations +* **Service** - Kubernetes Services referenced by Ingress backend configurations + +## Supported Annotations + +* `nginx.org/ssl-services` - SSL/TLS backend connections +* `nginx.org/grpc-services` - gRPC backend connections +* `nginx.org/websocket-services` - WebSocket backend connections +* `nginx.org/proxy-hide-headers` - Hide headers from responses +* `nginx.org/proxy-set-headers` - Set custom headers +* `nginx.org/listen-ports` - Custom HTTP ports +* `nginx.org/listen-ports-ssl` - Custom HTTPS ports +* `nginx.org/path-regex` - Regex path matching +* `nginx.org/rewrites` - URL rewriting +* `nginx.org/redirect-to-https` - SSL/HTTPS redirects +* `ingress.kubernetes.io/ssl-redirect` - Legacy SSL redirect (for compatibility) +* `nginx.org/hsts` - HTTP Strict Transport Security headers +* `nginx.org/hsts-max-age` - HSTS max-age directive +* `nginx.org/hsts-include-subdomains` - HSTS includeSubDomains directive + +## Usage + +```bash +# Convert NGINX Ingress Controller resources from cluster +ingress2gateway print --providers=nginx + +# Convert from file +ingress2gateway print --providers=nginx --input-file=nginx-ingress.yaml +``` + +## Gateway API Mapping + +| NGINX Annotation | Gateway API Resource | +|--------------------------------------|-----------------------------------| +| `nginx.org/ssl-services` | BackendTLSPolicy | +| `nginx.org/grpc-services` | GRPCRoute | +| `nginx.org/websocket-services` | Informational notification only | +| `nginx.org/proxy-hide-headers` | HTTPRoute ResponseHeaderModifier | +| `nginx.org/proxy-set-headers` | HTTPRoute RequestHeaderModifier | +| `nginx.org/rewrites` | HTTPRoute URLRewrite filter | +| `nginx.org/listen-ports*` | Gateway custom listeners | +| `nginx.org/path-regex` | HTTPRoute RegularExpression paths | +| `nginx.org/redirect-to-https` | HTTPRoute RequestRedirect filter | +| `ingress.kubernetes.io/ssl-redirect` | HTTPRoute RequestRedirect filter | +| `nginx.org/hsts*` | HTTPRoute ResponseHeaderModifier | + +## SSL Redirect Behavior + +The provider supports two SSL redirect annotations with identical behavior: + +* **`nginx.org/redirect-to-https`** - Redirects all HTTP traffic to HTTPS with a 301 status code +* **`ingress.kubernetes.io/ssl-redirect`** - Redirects all HTTP traffic to HTTPS with a 301 status code (legacy compatibility) + +## Contributing + +When adding support for new NGINX Ingress Controller annotations: + +1. Add the annotation constant to `annotations/constants.go` +2. Implement the conversion logic in the appropriate `annotations/*.go` file +3. Add comprehensive tests in `annotations/*_test.go` +4. Update this README with the new annotation details + +For more information on the provider architecture, see [PROVIDER.md](../../PROVIDER.md). + +## References + +* [NGINX Ingress Controller](https://github.com/nginx/kubernetes-ingress) +* [NGINX Ingress Controller Annotations](https://docs.nginx.com/nginx-ingress-controller/configuration/ingress-resources/advanced-configuration-with-annotations/) +* [NGINX Gateway Fabric](https://docs.nginx.com/nginx-gateway-fabric/) +* [Gateway API Documentation](https://gateway-api.sigs.k8s.io/) \ No newline at end of file diff --git a/pkg/i2gw/providers/nginx/annotations/README.md b/pkg/i2gw/providers/nginx/annotations/README.md new file mode 100644 index 000000000..03172dd74 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/README.md @@ -0,0 +1,119 @@ +# NGINX Ingress Annotations + +This directory contains the implementation of [NGINX Ingress Controller](https://github.com/nginx/kubernetes-ingress) annotations for the ingress2gateway conversion tool. + +**Note**: This is specifically for NGINX Ingress Controller, not the community [ingress-nginx](https://github.com/kubernetes/ingress-nginx) controller. + +## Structure + +- **`constants.go`** - All annotation constants and schema definitions +- **`ssl_services.go`** - SSL backend services (`ssl-services`) +- **`grpc_services.go`** - gRPC backend services (`grpc-services`) +- **`websocket_services.go`** - WebSocket backend services (`websocket-services`) +- **`header_manipulation.go`** - Header manipulation annotations (`hide-headers`, `proxy-set-headers`, etc.) +- **`hsts.go`** - HSTS header annotations (`hsts`) +- **`listen_ports.go`** - Custom port listeners (`listen-ports`, `listen-ports-ssl`) +- **`path_matching.go`** - Path regex matching (`path-regex`) +- **`path_rewrite.go`** - URL rewriting (`rewrites`) +- **`ssl_redirect.go`** - SSL/HTTPS redirects (`redirect-to-https`) + +## Exported Functions + +Each annotation file exports a main feature function: + +- `SSLServicesFeature` - Processes SSL backend services annotations +- `GRPCServicesFeature` - Processes gRPC backend services annotations +- `WebSocketServicesFeature` - Processes WebSocket backend services annotations +- `HeaderManipulationFeature` - Processes header manipulation annotations +- `HSTSFeature` - Processes HSTS header annotations +- `ListenPortsFeature` - Processes custom port listener annotations +- `PathRegexFeature` - Processes path regex annotations +- `RewriteTargetFeature` - Processes URL rewrite annotations +- `SSLRedirectFeature` - Processes SSL redirect annotations + +## Testing + +Each annotation implementation includes comprehensive unit tests: + +- `*_test.go` files contain feature-specific tests +- `*_helpers_test.go` files contain shared test utilities +- Tests cover various annotation formats, edge cases, and error conditions + +## Adding New Annotations + +To add a new NGINX annotation: + +1. Add the annotation constant to `constants.go` +2. Create the feature implementation file (e.g., `my_feature.go`) +3. Export the main feature function (e.g., `MyFeature`) +4. Add comprehensive tests in `my_feature_test.go` +5. Register the feature function in `../converter.go` + +## Limitations and Known Issues + +### Multiple Ingresses with Same Hostname + +When multiple Ingress resources define rules for the same hostname (within the same namespace and ingress class), all annotations from all Ingresses will be applied to the resulting HTTPRoute. This can lead to unexpected behavior where annotations from one Ingress affect traffic intended for another. + +**Example:** +```yaml +# ingress-app1.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app1 + annotations: + nginx.org/proxy-hide-headers: "Server,X-Powered-By" + nginx.org/proxy-set-headers: "X-App: app1" +spec: + rules: + - host: example.com + http: + paths: + - path: /app1 + backend: + service: + name: app1-service + port: + number: 80 + +--- +# ingress-app2.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app2 + annotations: + nginx.org/proxy-set-headers: "X-App: app2" + nginx.org/hsts: "true" +spec: + rules: + - host: example.com # Same hostname! + http: + paths: + - path: /app2 + backend: + service: + name: app2-service + port: + number: 80 +``` + +**Result:** The converted HTTPRoute will have ALL annotations applied: +- Both `X-App: app1` AND `X-App: app2` headers will be set on all rules +- `Server,X-Powered-By` headers will be hidden for all rules +- HSTS will be enabled for all rules +- This affects both `/app1` and `/app2` paths + +**Workarounds:** +1. **Separate Hostnames**: Use different hostnames for different applications (e.g., `app1.example.com`, `app2.example.com`) +2. **Single Ingress**: Combine all paths into a single Ingress resource with consistent annotations +3. **Post-Conversion Manual Editing**: Manually edit the generated HTTPRoutes to apply filters only to specific rules + +This limitation exists because the rule grouping mechanism groups rules by `namespace/ingressClass/hostname`, and annotation processing applies all discovered annotations to the entire rule group. +[Issue #229](https://github.com/kubernetes-sigs/ingress2gateway/issues/229) + + +## Integration + +These annotation handlers are integrated into the main NGINX provider via `../converter.go`, which registers all feature parsers with the conversion pipeline. \ No newline at end of file diff --git a/pkg/i2gw/providers/nginx/annotations/constants.go b/pkg/i2gw/providers/nginx/annotations/constants.go new file mode 100644 index 000000000..22f19defc --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/constants.go @@ -0,0 +1,62 @@ +/* +Copyright 2025 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 annotations + +const ( + // NGINX Ingress Controller annotation prefixes + nginxOrgPrefix = "nginx.org/" + + // Standard annotations that map directly to Gateway API + nginxRewritesAnnotation = nginxOrgPrefix + "rewrites" + nginxRedirectToHTTPSAnnotation = nginxOrgPrefix + "redirect-to-https" + + // Header manipulation annotations + nginxProxyHideHeadersAnnotation = nginxOrgPrefix + "proxy-hide-headers" + nginxProxyPassHeadersAnnotation = nginxOrgPrefix + "proxy-pass-headers" + nginxProxySetHeadersAnnotation = nginxOrgPrefix + "proxy-set-headers" + + // Port configuration annotations + nginxListenPortsAnnotation = nginxOrgPrefix + "listen-ports" + nginxListenPortsSSLAnnotation = nginxOrgPrefix + "listen-ports-ssl" + + // Backend service annotations + nginxSSLServicesAnnotation = nginxOrgPrefix + "ssl-services" + nginxGRPCServicesAnnotation = nginxOrgPrefix + "grpc-services" + nginxWebSocketServicesAnnotation = nginxOrgPrefix + "websocket-services" + + // Path matching annotations + nginxPathRegexAnnotation = nginxOrgPrefix + "path-regex" + + // Legacy SSL redirect annotation + legacySSLRedirectAnnotation = "ingress.kubernetes.io/ssl-redirect" + + // HSTS header annotation + nginxHSTSAnnotation = nginxOrgPrefix + "hsts" + nginxHSTSIncludeSubdomainsAnnotation = nginxOrgPrefix + "hsts-include-subdomains" + nginxHSTSMaxAgeAnnotation = nginxOrgPrefix + "hsts-max-age" +) + +// NginxIngressClass class name +const ( + NginxIngressClass = "nginx" +) + +// Resource kind constants +const ( + BackendTLSPolicyKind = "BackendTLSPolicy" + GRPCRouteKind = "GRPCRoute" +) diff --git a/pkg/i2gw/providers/nginx/annotations/grpc_services.go b/pkg/i2gw/providers/nginx/annotations/grpc_services.go new file mode 100644 index 000000000..873677207 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/grpc_services.go @@ -0,0 +1,231 @@ +/* +Copyright 2025 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 annotations + +import ( + 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" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/notifications" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +// GRPCServicesFeature processes nginx.org/grpc-services annotation +func GRPCServicesFeature(ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList + + ruleGroups := common.GetRuleGroups(ingresses) + for _, rg := range ruleGroups { + for _, rule := range rg.Rules { + if grpcServices, exists := rule.Ingress.Annotations[nginxGRPCServicesAnnotation]; exists && grpcServices != "" { + errs = append(errs, processGRPCServicesAnnotation(rule.Ingress, grpcServices, ir)...) + } + } + } + + return errs +} + +// processGRPCServicesAnnotation handles gRPC backend services +// +//nolint:unparam // ErrorList return type maintained for consistency +func processGRPCServicesAnnotation(ingress networkingv1.Ingress, grpcServices string, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList //nolint:unparam // ErrorList return type maintained for consistency + + // Parse comma-separated service names that should use gRPC + services := splitAndTrimCommaList(grpcServices) + grpcServiceSet := make(map[string]struct{}) + for _, service := range services { + grpcServiceSet[service] = struct{}{} + } + + // Initialize GRPCRoutes map if needed + if ir.GRPCRoutes == nil { + ir.GRPCRoutes = make(map[types.NamespacedName]gatewayv1.GRPCRoute) + } + + // Mark services as gRPC in provider-specific IR + if ir.Services == nil { + ir.Services = make(map[types.NamespacedName]intermediate.ProviderSpecificServiceIR) + } + + // Process each ingress rule that uses gRPC services + for _, rule := range ingress.Spec.Rules { + if rule.HTTP == nil { + continue + } + + routeName := common.RouteName(ingress.Name, rule.Host) + routeKey := types.NamespacedName{ + Namespace: ingress.Namespace, + Name: routeName, + } + + var grpcRouteRules []gatewayv1.GRPCRouteRule + var remainingHTTPRules []gatewayv1.HTTPRouteRule + + // Get existing HTTPRoute to copy filters and check for rules + httpRouteContext, httpRouteExists := ir.HTTPRoutes[routeKey] + + // Separate gRPC paths from non-gRPC paths + for _, path := range rule.HTTP.Paths { + serviceName := path.Backend.Service.Name + if _, exists := grpcServiceSet[serviceName]; exists { + // This path uses a gRPC service - create GRPCRoute rule + grpcMatch := gatewayv1.GRPCRouteMatch{} + + // Convert HTTP path to gRPC service/method match + if path.Path != "" { + service, method := common.ParseGRPCServiceMethod(path.Path) + if service != "" { + grpcMatch.Method = &gatewayv1.GRPCMethodMatch{ + Service: &service, + } + if method != "" { + grpcMatch.Method.Method = &method + } + } + } + + // Create backend reference + var port *gatewayv1.PortNumber + if path.Backend.Service.Port.Number != 0 { + portNum := gatewayv1.PortNumber(path.Backend.Service.Port.Number) + port = &portNum + } + + backendRef := gatewayv1.GRPCBackendRef{ + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName(serviceName), + Port: port, + }, + }, + } + + // Copy filters from HTTPRoute to GRPCRoute rule + var grpcFilters []gatewayv1.GRPCRouteFilter + if httpRouteExists { + // Find the corresponding HTTP rule for this path to copy its filters + grpcFilters = findAndConvertFiltersForGRPCPath(httpRouteContext.HTTPRoute.Spec.Rules, path.Path) + } + + grpcRule := gatewayv1.GRPCRouteRule{ + Matches: []gatewayv1.GRPCRouteMatch{grpcMatch}, + Filters: grpcFilters, + BackendRefs: []gatewayv1.GRPCBackendRef{backendRef}, + } + + grpcRouteRules = append(grpcRouteRules, grpcRule) + } + } + + // Create GRPCRoute if we have any gRPC rules + if len(grpcRouteRules) > 0 { + var hostnames []gatewayv1.Hostname + if rule.Host != "" { + hostnames = []gatewayv1.Hostname{gatewayv1.Hostname(rule.Host)} + } + + grpcRoute := gatewayv1.GRPCRoute{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gatewayv1.GroupVersion.String(), + Kind: GRPCRouteKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.GRPCRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: func() gatewayv1.ObjectName { + if ingress.Spec.IngressClassName != nil { + return gatewayv1.ObjectName(*ingress.Spec.IngressClassName) + } + return NginxIngressClass + }(), + }, + }, + }, + Hostnames: hostnames, + Rules: grpcRouteRules, + }, + } + + ir.GRPCRoutes[routeKey] = grpcRoute + + // Remove HTTP rules that correspond to gRPC services from the HTTPRoute + if httpRouteExists { + remainingHTTPRules = common.RemoveGRPCRulesFromHTTPRoute(&httpRouteContext.HTTPRoute, grpcServiceSet) + + // If no rules remain, remove the entire HTTPRoute + if len(remainingHTTPRules) == 0 { + delete(ir.HTTPRoutes, routeKey) + } else { + // Update HTTPRoute with remaining rules + httpRouteContext.HTTPRoute.Spec.Rules = remainingHTTPRules + ir.HTTPRoutes[routeKey] = httpRouteContext + } + } + } + } + + return errs +} + +// findAndConvertFiltersForGRPCPath finds the HTTP rule that matches the given path and converts its filters to gRPC filters +func findAndConvertFiltersForGRPCPath(httpRules []gatewayv1.HTTPRouteRule, grpcPath string) []gatewayv1.GRPCRouteFilter { + // Find the HTTP rule that contains this path + for _, httpRule := range httpRules { + for _, match := range httpRule.Matches { + if match.Path != nil && match.Path.Value != nil && *match.Path.Value == grpcPath { + // Found the matching rule, convert its filters + conversionResult := common.ConvertHTTPFiltersToGRPCFilters(httpRule.Filters) + + // Handle notifications for unsupported filters + for _, unsupportedType := range conversionResult.UnsupportedTypes { + switch unsupportedType { + case gatewayv1.HTTPRouteFilterRequestHeaderModifier: + // This should never happen as it's a supported filter, but added for exhaustiveness + notify(notifications.WarningNotification, "RequestHeaderModifier should be supported for gRPC") + case gatewayv1.HTTPRouteFilterResponseHeaderModifier: + // This should never happen as it's a supported filter, but added for exhaustiveness + notify(notifications.WarningNotification, "ResponseHeaderModifier should be supported for gRPC") + case gatewayv1.HTTPRouteFilterRequestRedirect: + notify(notifications.WarningNotification, "RequestRedirect is not applicable to gRPC") + case gatewayv1.HTTPRouteFilterURLRewrite: + notify(notifications.WarningNotification, "URLRewrite is not applicable to gRPC") + case gatewayv1.HTTPRouteFilterRequestMirror: + notify(notifications.WarningNotification, "RequestMirror is not applicable to gRPC") + case gatewayv1.HTTPRouteFilterExtensionRef: + notify(notifications.WarningNotification, "ExtensionRef filters are not converted to gRPC equivalents") + default: + notify(notifications.WarningNotification, "Unknown HTTPRouteFilter type: "+string(unsupportedType)) + } + } + return conversionResult.GRPCFilters + } + } + } + return nil +} diff --git a/pkg/i2gw/providers/nginx/annotations/grpc_services_test.go b/pkg/i2gw/providers/nginx/annotations/grpc_services_test.go new file mode 100644 index 000000000..3dee31901 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/grpc_services_test.go @@ -0,0 +1,367 @@ +/* +Copyright 2025 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 annotations + +import ( + "testing" + + 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" + gatewayv1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +func TestGRPCServicesRemoveHTTPRoute(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grpc-ingress", + Namespace: "default", + Annotations: map[string]string{ + nginxGRPCServicesAnnotation: "grpc-service", + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "grpc.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/grpc.service/Method", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "grpc-service", + Port: networkingv1.ServiceBackendPort{Number: 50051}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Setup IR with an existing HTTPRoute that should be removed + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + + ir := intermediate.IR{ + HTTPRoutes: map[types.NamespacedName]intermediate.HTTPRouteContext{ + routeKey: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + }, + }, + }, + GRPCRoutes: make(map[types.NamespacedName]gatewayv1.GRPCRoute), + BackendTLSPolicies: make(map[types.NamespacedName]gatewayv1alpha3.BackendTLSPolicy), + } + + // Verify HTTPRoute exists before + if _, exists := ir.HTTPRoutes[routeKey]; !exists { + t.Fatal("HTTPRoute should exist before calling GRPCServicesFeature") + } + + // Execute + errs := GRPCServicesFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Errorf("Unexpected errors: %v", errs) + return + } + + // Debug output + t.Logf("HTTPRoutes after: %d", len(ir.HTTPRoutes)) + t.Logf("GRPCRoutes after: %d", len(ir.GRPCRoutes)) + t.Logf("Expected routeKey: %s", routeKey) + for k := range ir.HTTPRoutes { + t.Logf("HTTPRoute key: %s", k) + } + for k := range ir.GRPCRoutes { + t.Logf("GRPCRoute key: %s", k) + } + + // Verify HTTPRoute was removed + if _, exists := ir.HTTPRoutes[routeKey]; exists { + t.Error("HTTPRoute should be removed for gRPC services") + } + + // Verify GRPCRoute was created + if _, exists := ir.GRPCRoutes[routeKey]; !exists { + t.Error("GRPCRoute should be created for gRPC services") + return // Don't continue testing structure if route doesn't exist + } + + // Verify GRPCRoute structure + grpcRoute := ir.GRPCRoutes[routeKey] + expectedRules := 1 + if len(grpcRoute.Spec.Rules) != expectedRules { + t.Errorf("Expected GRPCRoute to have %d rules, got %d", expectedRules, len(grpcRoute.Spec.Rules)) + return + } + + expectedBackendRefs := 1 + if len(grpcRoute.Spec.Rules[0].BackendRefs) != expectedBackendRefs { + t.Errorf("Expected GRPCRoute to have %d backend refs, got %d", expectedBackendRefs, len(grpcRoute.Spec.Rules[0].BackendRefs)) + return + } + + backendRef := grpcRoute.Spec.Rules[0].BackendRefs[0] + if string(backendRef.BackendRef.BackendObjectReference.Name) != "grpc-service" { + t.Errorf("Expected backend service 'grpc-service', got '%s'", backendRef.BackendRef.BackendObjectReference.Name) + } +} + +func TestGRPCServicesWithMixedServices(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mixed-ingress", + Namespace: "default", + Annotations: map[string]string{ + nginxGRPCServicesAnnotation: "grpc-service", + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "mixed.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/grpc.service/Method", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "grpc-service", + Port: networkingv1.ServiceBackendPort{Number: 50051}, + }, + }, + }, + { + Path: "/api/v1", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "http-service", + Port: networkingv1.ServiceBackendPort{Number: 8080}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Setup IR with existing HTTPRoute containing filters + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + + httpRouteRules := []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: ptr.To("/grpc.service/Method"), + }, + }, + }, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "grpc-service", + Port: ptr.To(gatewayv1.PortNumber(50051)), + }, + }, + }, + }, + Filters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "X-Custom-Header", Value: "test-value"}, + }, + }, + }, + { + Type: gatewayv1.HTTPRouteFilterResponseHeaderModifier, + ResponseHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Remove: []string{"Server"}, + }, + }, + }, + }, + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: ptr.To("/api/v1"), + }, + }, + }, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "http-service", + Port: ptr.To(gatewayv1.PortNumber(8080)), + }, + }, + }, + }, + Filters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Add: []gatewayv1.HTTPHeader{ + {Name: "X-API-Version", Value: "v1"}, + }, + }, + }, + }, + }, + } + + ir := intermediate.IR{ + HTTPRoutes: map[types.NamespacedName]intermediate.HTTPRouteContext{ + routeKey: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: httpRouteRules, + }, + }, + }, + }, + GRPCRoutes: make(map[types.NamespacedName]gatewayv1.GRPCRoute), + } + + // Execute + errs := GRPCServicesFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Errorf("Unexpected errors: %v", errs) + return + } + + // Verify HTTPRoute still exists (but modified) + httpRouteContext, httpExists := ir.HTTPRoutes[routeKey] + if !httpExists { + t.Error("HTTPRoute should still exist for mixed services") + return + } + + // Verify HTTPRoute only has non-gRPC rules + if len(httpRouteContext.HTTPRoute.Spec.Rules) != 1 { + t.Errorf("Expected 1 remaining HTTPRoute rule, got %d", len(httpRouteContext.HTTPRoute.Spec.Rules)) + return + } + + remainingRule := httpRouteContext.HTTPRoute.Spec.Rules[0] + if len(remainingRule.BackendRefs) != 1 { + t.Errorf("Expected 1 backend ref in remaining rule, got %d", len(remainingRule.BackendRefs)) + return + } + + if string(remainingRule.BackendRefs[0].BackendRef.BackendObjectReference.Name) != "http-service" { + t.Errorf("Expected remaining backend to be 'http-service', got '%s'", + remainingRule.BackendRefs[0].BackendRef.BackendObjectReference.Name) + } + + // Verify GRPCRoute was created + grpcRoute, grpcExists := ir.GRPCRoutes[routeKey] + if !grpcExists { + t.Error("GRPCRoute should be created for gRPC services") + return + } + + // Verify GRPCRoute structure + if len(grpcRoute.Spec.Rules) != 1 { + t.Errorf("Expected 1 GRPCRoute rule, got %d", len(grpcRoute.Spec.Rules)) + return + } + + grpcRule := grpcRoute.Spec.Rules[0] + if len(grpcRule.BackendRefs) != 1 { + t.Errorf("Expected 1 gRPC backend ref, got %d", len(grpcRule.BackendRefs)) + return + } + + if string(grpcRule.BackendRefs[0].BackendRef.BackendObjectReference.Name) != "grpc-service" { + t.Errorf("Expected gRPC backend to be 'grpc-service', got '%s'", + grpcRule.BackendRefs[0].BackendRef.BackendObjectReference.Name) + } + + // Verify filters were copied to GRPCRoute + if len(grpcRule.Filters) != 2 { + t.Errorf("Expected 2 filters in GRPCRoute rule, got %d", len(grpcRule.Filters)) + return + } + + // Check RequestHeaderModifier filter + var hasRequestFilter, hasResponseFilter bool + for _, filter := range grpcRule.Filters { + if filter.Type == gatewayv1.GRPCRouteFilterRequestHeaderModifier { + hasRequestFilter = true + if filter.RequestHeaderModifier == nil { + t.Error("RequestHeaderModifier should not be nil") + } else if len(filter.RequestHeaderModifier.Set) != 1 || + string(filter.RequestHeaderModifier.Set[0].Name) != "X-Custom-Header" || + filter.RequestHeaderModifier.Set[0].Value != "test-value" { + t.Error("RequestHeaderModifier not correctly copied") + } + } + if filter.Type == gatewayv1.GRPCRouteFilterResponseHeaderModifier { + hasResponseFilter = true + if filter.ResponseHeaderModifier == nil { + t.Error("ResponseHeaderModifier should not be nil") + } else if len(filter.ResponseHeaderModifier.Remove) != 1 || + filter.ResponseHeaderModifier.Remove[0] != "Server" { + t.Error("ResponseHeaderModifier not correctly copied") + } + } + } + + if !hasRequestFilter { + t.Error("GRPCRoute should have RequestHeaderModifier filter") + } + if !hasResponseFilter { + t.Error("GRPCRoute should have ResponseHeaderModifier filter") + } +} diff --git a/pkg/i2gw/providers/nginx/annotations/header_manipulation.go b/pkg/i2gw/providers/nginx/annotations/header_manipulation.go new file mode 100644 index 000000000..973675634 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/header_manipulation.go @@ -0,0 +1,164 @@ +/* +Copyright 2025 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 annotations + +import ( + "fmt" + "strings" + + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +// HeaderManipulationFeature converts header manipulation annotations to HTTPRoute filters +func HeaderManipulationFeature(ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList + + ruleGroups := common.GetRuleGroups(ingresses) + for _, rg := range ruleGroups { + for _, rule := range rg.Rules { + // Get the HTTPRoute for this rule group + key := types.NamespacedName{Namespace: rule.Ingress.Namespace, Name: common.RouteName(rg.Name, rg.Host)} + httpRouteContext, ok := ir.HTTPRoutes[key] + if !ok { + return field.ErrorList{field.InternalError(nil, fmt.Errorf("HTTPRoute does not exist - common HTTPRoute generation failed"))} + } + + // Process proxy-hide-headers annotation + if hideHeaders, exists := rule.Ingress.Annotations[nginxProxyHideHeadersAnnotation]; exists && hideHeaders != "" { + filter := createResponseHeaderModifier(hideHeaders) + if filter != nil { + errs = append(errs, addFilterToHTTPRoute(&httpRouteContext.HTTPRoute, rule.Ingress, *filter)...) + } + } + + // Process proxy-set-headers annotation + if setHeaders, exists := rule.Ingress.Annotations[nginxProxySetHeadersAnnotation]; exists && setHeaders != "" { + filter := createRequestHeaderModifier(setHeaders) + if filter != nil { + errs = append(errs, addFilterToHTTPRoute(&httpRouteContext.HTTPRoute, rule.Ingress, *filter)...) + } + } + + // Update the HTTPRoute in the IR + ir.HTTPRoutes[key] = httpRouteContext + } + } + + return errs +} + +// addFilterToHTTPRoute adds a filter to all HTTPRoute rules +// +//nolint:unparam // ErrorList return type maintained for consistency +func addFilterToHTTPRoute(httpRoute *gatewayv1.HTTPRoute, _ networkingv1.Ingress, filter gatewayv1.HTTPRouteFilter) field.ErrorList { + var errs field.ErrorList + + // Apply filter to all rules + for i := range httpRoute.Spec.Rules { + if httpRoute.Spec.Rules[i].Filters == nil { + httpRoute.Spec.Rules[i].Filters = []gatewayv1.HTTPRouteFilter{} + } + httpRoute.Spec.Rules[i].Filters = append(httpRoute.Spec.Rules[i].Filters, filter) + } + + return errs +} + +// createResponseHeaderModifier creates a ResponseHeaderModifier filter from comma-separated header names +func createResponseHeaderModifier(hideHeaders string) *gatewayv1.HTTPRouteFilter { + headersToRemove := parseCommaSeparatedHeaders(hideHeaders) + if len(headersToRemove) == 0 { + return nil + } + + return &gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterResponseHeaderModifier, + ResponseHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Remove: headersToRemove, + }, + } +} + +// createRequestHeaderModifier creates a RequestHeaderModifier filter from proxy-set-headers annotation +func createRequestHeaderModifier(setHeaders string) *gatewayv1.HTTPRouteFilter { + headers := parseSetHeaders(setHeaders) + if len(headers) == 0 { + return nil + } + + var headersToSet []gatewayv1.HTTPHeader + for name, value := range headers { + if value != "" && !strings.Contains(value, "$") { + headersToSet = append(headersToSet, gatewayv1.HTTPHeader{ + Name: gatewayv1.HTTPHeaderName(name), + Value: value, + }) + } + // Note: Headers with NGINX variables cannot be converted to Gateway API + // as Gateway API doesn't support dynamic header values + } + + if len(headersToSet) == 0 { + return nil + } + + return &gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: headersToSet, + }, + } +} + +// parseCommaSeparatedHeaders parses a comma-separated list of header names +func parseCommaSeparatedHeaders(headersList string) []string { + return splitAndTrimCommaList(headersList) +} + +// parseSetHeaders parses nginx.org/proxy-set-headers annotation format +// Supports both header names and header:value pairs +func parseSetHeaders(setHeaders string) map[string]string { + headers := make(map[string]string) + parts := splitAndTrimCommaList(setHeaders) + + for _, part := range parts { + if strings.Contains(part, ":") { + // Format: "Header-Name: value" + kv := strings.SplitN(part, ":", 2) + if len(kv) == 2 { + headerName := strings.TrimSpace(kv[0]) + headerValue := strings.TrimSpace(kv[1]) + if headerName != "" { + headers[headerName] = headerValue + } + } + } + // Note: Headers without explicit values (format "$Variable-Name") are skipped + // as Gateway API cannot use NGINX variables like $http_* and headers need values + } + + return headers +} + +// Note: The patchHTTPRouteHeaderMatching function has been removed as it was incomplete. +// Header matching should be implemented separately if needed for specific NGINX features. diff --git a/pkg/i2gw/providers/nginx/annotations/header_manipulation_test.go b/pkg/i2gw/providers/nginx/annotations/header_manipulation_test.go new file mode 100644 index 000000000..01b3cb337 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/header_manipulation_test.go @@ -0,0 +1,834 @@ +/* +Copyright 2025 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 annotations + +import ( + "reflect" + "testing" + + 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" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +// Common test data +var ( + testIngressSpec = networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + } +) + +// Helper functions for test setup +func createTestIngress(name, namespace string, annotations map[string]string) networkingv1.Ingress { + return networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: testIngressSpec, + } +} + +func TestParseSetHeaders(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "empty input", + input: "", + expected: map[string]string{}, + }, + { + name: "single header name only", + input: "X-Custom-Header", + expected: map[string]string{}, // Headers without values should not be added + }, + { + name: "single header with value", + input: "X-Custom-Header: custom-value", + expected: map[string]string{ + "X-Custom-Header": "custom-value", + }, + }, + { + name: "multiple headers names only", + input: "X-Header1,X-Header2,X-Header3", + expected: map[string]string{}, // Headers without values should not be added + }, + { + name: "multiple headers with values", + input: "X-Header1: value1,X-Header2: value2", + expected: map[string]string{ + "X-Header1": "value1", + "X-Header2": "value2", + }, + }, + { + name: "mixed format", + input: "X-Default-Header,X-Custom-Header: custom-value,X-Another-Header", + expected: map[string]string{ + // Only headers with explicit values should be included + "X-Custom-Header": "custom-value", + }, + }, + { + name: "headers with spaces", + input: " X-Header1 : value1 , X-Header2 : value2 ", + expected: map[string]string{ + "X-Header1": "value1", + "X-Header2": "value2", + }, + }, + { + name: "complex header values", + input: "X-Forwarded-For: $remote_addr,X-Real-IP: $remote_addr,X-Custom: hello-world", + expected: map[string]string{ + "X-Forwarded-For": "$remote_addr", + "X-Real-IP": "$remote_addr", + "X-Custom": "hello-world", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseSetHeaders(tt.input) + + if len(result) != len(tt.expected) { + t.Errorf("Expected %d headers, got %d", len(tt.expected), len(result)) + } + + for expectedName, expectedValue := range tt.expected { + if actualValue, exists := result[expectedName]; !exists { + t.Errorf("Expected header %s not found", expectedName) + } else if actualValue != expectedValue { + t.Errorf("Header %s: expected value %q, got %q", expectedName, expectedValue, actualValue) + } + } + }) + } +} + +func TestHideHeaders(t *testing.T) { + tests := []struct { + name string + hideHeaders string + expectedHeaders []string + }{ + { + name: "single header", + hideHeaders: "Server", + expectedHeaders: []string{"Server"}, + }, + { + name: "multiple headers", + hideHeaders: "Server,X-Powered-By,X-Version", + expectedHeaders: []string{"Server", "X-Powered-By", "X-Version"}, + }, + { + name: "headers with spaces", + hideHeaders: " Server , X-Powered-By , X-Version ", + expectedHeaders: []string{"Server", "X-Powered-By", "X-Version"}, + }, + { + name: "empty headers filtered out", + hideHeaders: "Server,,X-Powered-By,", + expectedHeaders: []string{"Server", "X-Powered-By"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := createTestIngress("test-ingress", "default", map[string]string{ + nginxProxyHideHeadersAnnotation: tt.hideHeaders, + }) + + ir := intermediate.IR{ + Gateways: make(map[types.NamespacedName]intermediate.GatewayContext), + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + ir.HTTPRoutes[routeKey] = intermediate.HTTPRouteContext{ + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("web-service"), + Port: ptr.To(gatewayv1.PortNumber(80)), + }, + }, + }, + }, + }, + }, + }, + }, + } + + filter := createResponseHeaderModifier(tt.hideHeaders) + if filter == nil { + t.Error("Expected filter to be created") + return + } + // Apply filter to first rule (simplified for test) + var errs field.ErrorList + httpRouteContext := ir.HTTPRoutes[routeKey] + if len(httpRouteContext.HTTPRoute.Spec.Rules) > 0 { + if httpRouteContext.HTTPRoute.Spec.Rules[0].Filters == nil { + httpRouteContext.HTTPRoute.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{} + } + httpRouteContext.HTTPRoute.Spec.Rules[0].Filters = append(httpRouteContext.HTTPRoute.Spec.Rules[0].Filters, *filter) + ir.HTTPRoutes[routeKey] = httpRouteContext + } + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + updatedRoute := ir.HTTPRoutes[routeKey].HTTPRoute + if len(updatedRoute.Spec.Rules) == 0 { + t.Error("Expected at least one rule") + return + } + + rule := updatedRoute.Spec.Rules[0] + if len(rule.Filters) != 1 { + t.Errorf("Expected 1 filter, got %d", len(rule.Filters)) + return + } + + filter = &rule.Filters[0] + if filter.Type != gatewayv1.HTTPRouteFilterResponseHeaderModifier { + t.Errorf("Expected ResponseHeaderModifier filter, got %s", filter.Type) + return + } + + if filter.ResponseHeaderModifier == nil { + t.Error("Expected ResponseHeaderModifier to be non-nil") + return + } + + if len(filter.ResponseHeaderModifier.Remove) != len(tt.expectedHeaders) { + t.Errorf("Expected %d headers to remove, got %d", len(tt.expectedHeaders), len(filter.ResponseHeaderModifier.Remove)) + return + } + + for _, expectedHeader := range tt.expectedHeaders { + found := false + for _, actualHeader := range filter.ResponseHeaderModifier.Remove { + if actualHeader == expectedHeader { + found = true + break + } + } + if !found { + t.Errorf("Expected header %s not found in remove list", expectedHeader) + } + } + }) + } +} + +func TestSetHeaders(t *testing.T) { + tests := []struct { + name string + setHeaders string + expectedHeaders []gatewayv1.HTTPHeader + }{ + { + name: "single header with value", + setHeaders: "X-Custom: hello-world", + expectedHeaders: []gatewayv1.HTTPHeader{ + {Name: "X-Custom", Value: "hello-world"}, + }, + }, + { + name: "multiple headers with values", + setHeaders: "X-Custom: hello-world,X-Version: 1.0.0", + expectedHeaders: []gatewayv1.HTTPHeader{ + {Name: "X-Custom", Value: "hello-world"}, + {Name: "X-Version", Value: "1.0.0"}, + }, + }, + { + name: "nginx variables filtered out", + setHeaders: "X-Real-IP: $remote_addr,X-Custom: hello-world", + expectedHeaders: []gatewayv1.HTTPHeader{ + {Name: "X-Custom", Value: "hello-world"}, + }, + }, + { + name: "empty values filtered out", + setHeaders: "X-Empty-Header,X-Custom: hello-world", + expectedHeaders: []gatewayv1.HTTPHeader{ + {Name: "X-Custom", Value: "hello-world"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := createTestIngress("test-ingress", "default", map[string]string{ + nginxProxySetHeadersAnnotation: tt.setHeaders, + }) + + ir := intermediate.IR{ + Gateways: make(map[types.NamespacedName]intermediate.GatewayContext), + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + ir.HTTPRoutes[routeKey] = intermediate.HTTPRouteContext{ + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("web-service"), + Port: ptr.To(gatewayv1.PortNumber(80)), + }, + }, + }, + }, + }, + }, + }, + }, + } + + filter := createRequestHeaderModifier(tt.setHeaders) + var errs field.ErrorList + if filter != nil { + // Apply filter to first rule (simplified for test) + httpRouteContext := ir.HTTPRoutes[routeKey] + if len(httpRouteContext.HTTPRoute.Spec.Rules) > 0 { + if httpRouteContext.HTTPRoute.Spec.Rules[0].Filters == nil { + httpRouteContext.HTTPRoute.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{} + } + httpRouteContext.HTTPRoute.Spec.Rules[0].Filters = append(httpRouteContext.HTTPRoute.Spec.Rules[0].Filters, *filter) + ir.HTTPRoutes[routeKey] = httpRouteContext + } + } + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + updatedRoute := ir.HTTPRoutes[routeKey].HTTPRoute + if len(updatedRoute.Spec.Rules) == 0 { + t.Error("Expected at least one rule") + return + } + + rule := updatedRoute.Spec.Rules[0] + if len(tt.expectedHeaders) == 0 { + if len(rule.Filters) > 0 { + t.Errorf("Expected no filters, got %d", len(rule.Filters)) + } + return + } + + if len(rule.Filters) != 1 { + t.Errorf("Expected 1 filter, got %d", len(rule.Filters)) + return + } + + filter = &rule.Filters[0] + if filter.Type != gatewayv1.HTTPRouteFilterRequestHeaderModifier { + t.Errorf("Expected RequestHeaderModifier filter, got %s", filter.Type) + return + } + + if filter.RequestHeaderModifier == nil { + t.Error("Expected RequestHeaderModifier to be non-nil") + return + } + + if len(filter.RequestHeaderModifier.Set) != len(tt.expectedHeaders) { + t.Errorf("Expected %d headers to set, got %d", len(tt.expectedHeaders), len(filter.RequestHeaderModifier.Set)) + return + } + + for _, expectedHeader := range tt.expectedHeaders { + found := false + for _, actualHeader := range filter.RequestHeaderModifier.Set { + if actualHeader.Name == expectedHeader.Name && actualHeader.Value == expectedHeader.Value { + found = true + break + } + } + if !found { + t.Errorf("Expected header %s: %s not found in set list", expectedHeader.Name, expectedHeader.Value) + } + } + }) + } +} + +func TestHeaderManipulationFeature(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expectedHideHeaders []string + expectedSetHeaders []gatewayv1.HTTPHeader + }{ + { + name: "both hide and set headers", + annotations: map[string]string{ + nginxProxyHideHeadersAnnotation: "Server,X-Powered-By", + nginxProxySetHeadersAnnotation: "X-Custom: hello-world", + }, + expectedHideHeaders: []string{"Server", "X-Powered-By"}, + expectedSetHeaders: []gatewayv1.HTTPHeader{ + {Name: "X-Custom", Value: "hello-world"}, + }, + }, + { + name: "only hide headers", + annotations: map[string]string{ + nginxProxyHideHeadersAnnotation: "Server", + }, + expectedHideHeaders: []string{"Server"}, + expectedSetHeaders: []gatewayv1.HTTPHeader{}, + }, + { + name: "only set headers", + annotations: map[string]string{ + nginxProxySetHeadersAnnotation: "X-Custom: hello-world", + }, + expectedHideHeaders: []string{}, + expectedSetHeaders: []gatewayv1.HTTPHeader{ + {Name: "X-Custom", Value: "hello-world"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := createTestIngress("test-ingress", "default", tt.annotations) + + ir := intermediate.IR{ + Gateways: make(map[types.NamespacedName]intermediate.GatewayContext), + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + ir.HTTPRoutes[routeKey] = intermediate.HTTPRouteContext{ + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("web-service"), + Port: ptr.To(gatewayv1.PortNumber(80)), + }, + }, + }, + }, + }, + }, + }, + }, + } + + errs := HeaderManipulationFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + updatedRoute := ir.HTTPRoutes[routeKey].HTTPRoute + if len(updatedRoute.Spec.Rules) == 0 { + t.Error("Expected at least one rule") + return + } + + rule := updatedRoute.Spec.Rules[0] + + expectedFilterCount := 0 + if len(tt.expectedHideHeaders) > 0 { + expectedFilterCount++ + } + if len(tt.expectedSetHeaders) > 0 { + expectedFilterCount++ + } + + if len(rule.Filters) != expectedFilterCount { + t.Fatalf("Expected %d filters, got %d", expectedFilterCount, len(rule.Filters)) + } + + var responseHeaderFilter *gatewayv1.HTTPRouteFilter + var requestHeaderFilter *gatewayv1.HTTPRouteFilter + + for i := range rule.Filters { + filter := &rule.Filters[i] + if filter.Type == gatewayv1.HTTPRouteFilterResponseHeaderModifier { + responseHeaderFilter = filter + } else if filter.Type == gatewayv1.HTTPRouteFilterRequestHeaderModifier { + requestHeaderFilter = filter + } + } + + if len(tt.expectedHideHeaders) > 0 { + if responseHeaderFilter == nil { + t.Fatal("Expected ResponseHeaderModifier filter") + } + if len(responseHeaderFilter.ResponseHeaderModifier.Remove) != len(tt.expectedHideHeaders) { + t.Fatalf("Expected %d headers to remove, got %d", len(tt.expectedHideHeaders), len(responseHeaderFilter.ResponseHeaderModifier.Remove)) + } + } + + if len(tt.expectedSetHeaders) > 0 { + if requestHeaderFilter == nil { + t.Fatal("Expected RequestHeaderModifier filter") + } + if len(requestHeaderFilter.RequestHeaderModifier.Set) != len(tt.expectedSetHeaders) { + t.Fatalf("Expected %d headers to set, got %d", len(tt.expectedSetHeaders), len(requestHeaderFilter.RequestHeaderModifier.Set)) + } + } + }) + } +} + +func TestParseCommaSeparatedHeaders(t *testing.T) { + testCases := []struct { + name string + input string + expected []string + }{ + { + name: "empty input", + input: "", + expected: nil, + }, + { + name: "single header", + input: "Server", + expected: []string{"Server"}, + }, + { + name: "multiple headers", + input: "Server,X-Powered-By,X-Version", + expected: []string{"Server", "X-Powered-By", "X-Version"}, + }, + { + name: "headers with spaces", + input: " Server , X-Powered-By , X-Version ", + expected: []string{"Server", "X-Powered-By", "X-Version"}, + }, + { + name: "empty headers filtered out", + input: "Server,,X-Powered-By,", + expected: []string{"Server", "X-Powered-By"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := parseCommaSeparatedHeaders(tc.input) + if !reflect.DeepEqual(result, tc.expected) { + t.Errorf("Expected %v, got %v", tc.expected, result) + } + }) + } +} + +func TestCreateResponseHeaderModifier(t *testing.T) { + testCases := []struct { + name string + input string + expectedFilter *gatewayv1.HTTPRouteFilter + }{ + { + name: "empty input", + input: "", + expectedFilter: nil, + }, + { + name: "single header", + input: "Server", + expectedFilter: &gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterResponseHeaderModifier, + ResponseHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Remove: []string{"Server"}, + }, + }, + }, + { + name: "multiple headers", + input: "Server,X-Powered-By,X-Version", + expectedFilter: &gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterResponseHeaderModifier, + ResponseHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Remove: []string{"Server", "X-Powered-By", "X-Version"}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := createResponseHeaderModifier(tc.input) + if !reflect.DeepEqual(result, tc.expectedFilter) { + t.Errorf("Expected %+v, got %+v", tc.expectedFilter, result) + } + }) + } +} + +func TestCreateRequestHeaderModifier(t *testing.T) { + testCases := []struct { + name string + input string + expectedFilter *gatewayv1.HTTPRouteFilter + }{ + { + name: "empty input", + input: "", + expectedFilter: nil, + }, + { + name: "single header with value", + input: "X-Custom: hello-world", + expectedFilter: &gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "X-Custom", Value: "hello-world"}, + }, + }, + }, + }, + { + name: "multiple headers with values", + input: "X-Custom: hello-world,X-Version: 1.0.0", + // Don't check exact filter here due to map iteration order + expectedFilter: nil, // Will be verified manually in test + }, + { + name: "headers with NGINX variables filtered out", + input: "X-Real-IP: $remote_addr", + expectedFilter: nil, + }, + { + name: "headers with empty values filtered out", + input: "X-Empty-Header", + expectedFilter: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := createRequestHeaderModifier(tc.input) + + // Special handling for multiple headers test due to map iteration order + if tc.name == "multiple headers with values" { + if result == nil { + t.Error("Expected non-nil filter for multiple headers") + return + } + if result.Type != gatewayv1.HTTPRouteFilterRequestHeaderModifier { + t.Errorf("Expected RequestHeaderModifier type, got %s", result.Type) + return + } + if result.RequestHeaderModifier == nil { + t.Error("Expected RequestHeaderModifier to be non-nil") + return + } + if len(result.RequestHeaderModifier.Set) != 2 { + t.Errorf("Expected 2 headers, got %d", len(result.RequestHeaderModifier.Set)) + return + } + // Check headers exist (order may vary due to map iteration) + headers := make(map[string]string) + for _, h := range result.RequestHeaderModifier.Set { + headers[string(h.Name)] = h.Value + } + if headers["X-Custom"] != "hello-world" { + t.Errorf("Expected X-Custom: hello-world, got %s", headers["X-Custom"]) + } + if headers["X-Version"] != "1.0.0" { + t.Errorf("Expected X-Version: 1.0.0, got %s", headers["X-Version"]) + } + return + } + + if !reflect.DeepEqual(result, tc.expectedFilter) { + t.Errorf("Expected %+v, got %+v", tc.expectedFilter, result) + } + }) + } +} + +// Additional tests for behavior with source ingress mapping +func TestHeaderManipulationWithSourceIngressMapping(t *testing.T) { + // Test that filters are applied only to the correct rules based on source ingress mapping + ingress1 := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app1", + Namespace: "test", + Annotations: map[string]string{ + nginxProxyHideHeadersAnnotation: "Server,X-Powered-By", + nginxProxySetHeadersAnnotation: "X-Custom-Header: value1", + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/app1", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "app1-service", + Port: networkingv1.ServiceBackendPort{Number: 8080}, + }, + }, + }}, + }, + }, + }}, + }, + } + + ingress2 := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app2", + Namespace: "test", + Annotations: map[string]string{ + nginxProxySetHeadersAnnotation: "X-App: app2", + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/app2", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "app2-service", + Port: networkingv1.ServiceBackendPort{Number: 3000}, + }, + }, + }}, + }, + }, + }}, + }, + } + + // Create HTTPRoute with source ingress mapping annotation + httpRoute := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app1-example-com", + Namespace: "test", + Annotations: map[string]string{ + "ingress2gateway.io/source-ingress-rules": "test/app1:0;test/app2:1", + }, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + {}, // Rule 0 - from app1 + {}, // Rule 1 - from app2 + }, + }, + } + + ir := intermediate.IR{ + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + routeKey := types.NamespacedName{Namespace: "test", Name: "app1-example-com"} + ir.HTTPRoutes[routeKey] = intermediate.HTTPRouteContext{HTTPRoute: httpRoute} + + // Apply header manipulation + errs := HeaderManipulationFeature([]networkingv1.Ingress{ingress1, ingress2}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("HeaderManipulationFeature returned errors: %v", errs) + } + + // Verify filters applied correctly + updatedRoute := ir.HTTPRoutes[routeKey].HTTPRoute + + // Both rules should have 3 filters total (from app1: hide + set headers, from app2: set header) + // Since we no longer use source ingress mapping, all filters are applied to all rules + if len(updatedRoute.Spec.Rules[0].Filters) != 3 { + t.Errorf("Rule 0: expected 3 filters, got %d", len(updatedRoute.Spec.Rules[0].Filters)) + } + + if len(updatedRoute.Spec.Rules[1].Filters) != 3 { + t.Errorf("Rule 1: expected 3 filters, got %d", len(updatedRoute.Spec.Rules[1].Filters)) + } +} diff --git a/pkg/i2gw/providers/nginx/annotations/hsts.go b/pkg/i2gw/providers/nginx/annotations/hsts.go new file mode 100644 index 000000000..8739da621 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/hsts.go @@ -0,0 +1,122 @@ +/* +Copyright 2025 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 annotations + +import ( + "strconv" + "strings" + + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/notifications" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +// HSTSFeature converts HSTS annotations to HTTPRoute ResponseHeaderModifier filters. +// Supports nginx.org/hsts, nginx.org/hsts-max-age, and nginx.org/hsts-include-subdomains annotations. +func HSTSFeature(ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList + + ruleGroups := common.GetRuleGroups(ingresses) + for _, rg := range ruleGroups { + for _, rule := range rg.Rules { + if hsts, ok := rule.Ingress.Annotations[nginxHSTSAnnotation]; ok && hsts == "true" { + errs = append(errs, processHSTSAnnotation(rule.Ingress, ir)...) + } + } + } + + return errs +} + +//nolint:unparam // ErrorList return type maintained for consistency +func processHSTSAnnotation(ingress networkingv1.Ingress, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList + + hstsHeader := "Strict-Transport-Security" + hstsMaxAge := "31536000" // Default max-age of 1 year + hstsIncludeSubdomain := false + + // Parse the HSTS max-age value + if maxAge, ok := ingress.Annotations[nginxHSTSMaxAgeAnnotation]; ok && maxAge != "" { + if _, err := strconv.Atoi(maxAge); err != nil { + notify(notifications.ErrorNotification, "nginx.org/hsts-max-age: Invalid max-age value, must be a number", &ingress) + // Continue with default value instead of failing + } else { + hstsMaxAge = maxAge + } + } + + if includeSubdomains, ok := ingress.Annotations[nginxHSTSIncludeSubdomainsAnnotation]; ok && includeSubdomains == "true" { + hstsIncludeSubdomain = true + } + + hstsHeaderValue := buildHSTS(hstsMaxAge, hstsIncludeSubdomain) + + for _, rule := range ingress.Spec.Rules { + if rule.HTTP == nil { + continue + } + + routeName := common.RouteName(ingress.Name, rule.Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + + httpRouteContext, exists := ir.HTTPRoutes[routeKey] + if !exists { + continue + } + + for i := range httpRouteContext.HTTPRoute.Spec.Rules { + if httpRouteContext.HTTPRoute.Spec.Rules[i].Filters == nil { + httpRouteContext.HTTPRoute.Spec.Rules[i].Filters = []gatewayv1.HTTPRouteFilter{} + } + + filter := gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterResponseHeaderModifier, + ResponseHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + { + Name: gatewayv1.HTTPHeaderName(hstsHeader), + Value: hstsHeaderValue, + }, + }, + }, + } + + httpRouteContext.HTTPRoute.Spec.Rules[i].Filters = append(httpRouteContext.HTTPRoute.Spec.Rules[i].Filters, filter) + } + + // Update the route context in the IR + ir.HTTPRoutes[routeKey] = httpRouteContext + } + + return errs +} + +func buildHSTS(hstsMaxAge string, hstsIncludeSubdomain bool) string { + parts := []string{ + "max-age=" + hstsMaxAge, + } + if hstsIncludeSubdomain { + parts = append(parts, "includeSubDomains") + } + return strings.Join(parts, "; ") +} diff --git a/pkg/i2gw/providers/nginx/annotations/hsts_test.go b/pkg/i2gw/providers/nginx/annotations/hsts_test.go new file mode 100644 index 000000000..838bd44e7 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/hsts_test.go @@ -0,0 +1,294 @@ +/* +Copyright 2025 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 annotations + +import ( + "testing" + + 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" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +func TestHSTSFeature(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expectHSTS bool + expectedMaxAge string + expectedSubdomains bool + }{ + { + name: "HSTS enabled with defaults", + annotations: map[string]string{ + nginxHSTSAnnotation: "true", + }, + expectHSTS: true, + expectedMaxAge: "31536000", + expectedSubdomains: false, + }, + { + name: "HSTS with custom max-age", + annotations: map[string]string{ + nginxHSTSAnnotation: "true", + nginxHSTSMaxAgeAnnotation: "86400", + }, + expectHSTS: true, + expectedMaxAge: "86400", + expectedSubdomains: false, + }, + { + name: "HSTS with include subdomains", + annotations: map[string]string{ + nginxHSTSAnnotation: "true", + nginxHSTSIncludeSubdomainsAnnotation: "true", + }, + expectHSTS: true, + expectedMaxAge: "31536000", + expectedSubdomains: true, + }, + { + name: "HSTS with all options", + annotations: map[string]string{ + nginxHSTSAnnotation: "true", + nginxHSTSMaxAgeAnnotation: "604800", + nginxHSTSIncludeSubdomainsAnnotation: "true", + }, + expectHSTS: true, + expectedMaxAge: "604800", + expectedSubdomains: true, + }, + { + name: "HSTS disabled", + annotations: map[string]string{ + nginxHSTSAnnotation: "false", + }, + expectHSTS: false, + }, + { + name: "no HSTS annotation", + annotations: map[string]string{}, + expectHSTS: false, + }, + { + name: "invalid max-age falls back to default", + annotations: map[string]string{ + nginxHSTSAnnotation: "true", + nginxHSTSMaxAgeAnnotation: "invalid", + }, + expectHSTS: true, + expectedMaxAge: "31536000", // Should use default + expectedSubdomains: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: tt.annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Setup IR with existing HTTPRoute + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + + ir := intermediate.IR{ + HTTPRoutes: map[types.NamespacedName]intermediate.HTTPRouteContext{ + routeKey: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: "nginx", + }, + }, + }, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "web-service", + Port: ptr.To(gatewayv1.PortNumber(80)), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Execute + errs := HSTSFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + // Verify results + httpRoute := ir.HTTPRoutes[routeKey].HTTPRoute + + if !tt.expectHSTS { + // Should not have added HSTS filter + if len(httpRoute.Spec.Rules) > 0 && len(httpRoute.Spec.Rules[0].Filters) > 0 { + for _, filter := range httpRoute.Spec.Rules[0].Filters { + if filter.Type == gatewayv1.HTTPRouteFilterResponseHeaderModifier { + if filter.ResponseHeaderModifier != nil { + for _, header := range filter.ResponseHeaderModifier.Set { + if header.Name == "Strict-Transport-Security" { + t.Error("Expected no HSTS filter, but found one") + } + } + } + } + } + } + return + } + + // Verify HSTS filter was added + if len(httpRoute.Spec.Rules) == 0 { + t.Error("Expected HTTPRoute to have rules") + return + } + + rule := httpRoute.Spec.Rules[0] + if len(rule.Filters) == 0 { + t.Error("Expected HTTPRoute rule to have filters") + return + } + + // Find the HSTS filter + var hstsFilter *gatewayv1.HTTPRouteFilter + for i, filter := range rule.Filters { + if filter.Type == gatewayv1.HTTPRouteFilterResponseHeaderModifier { + if filter.ResponseHeaderModifier != nil { + for _, header := range filter.ResponseHeaderModifier.Set { + if header.Name == "Strict-Transport-Security" { + hstsFilter = &rule.Filters[i] + break + } + } + } + } + } + + if hstsFilter == nil { + t.Error("Expected HSTS ResponseHeaderModifier filter") + return + } + + // Verify the HSTS header value + var hstsHeaderValue string + for _, header := range hstsFilter.ResponseHeaderModifier.Set { + if header.Name == "Strict-Transport-Security" { + hstsHeaderValue = header.Value + break + } + } + + expectedValue := buildHSTS(tt.expectedMaxAge, tt.expectedSubdomains) + if hstsHeaderValue != expectedValue { + t.Errorf("Expected HSTS header value %q, got %q", expectedValue, hstsHeaderValue) + } + }) + } +} + +func TestBuildHSTS(t *testing.T) { + tests := []struct { + name string + maxAge string + includeSubdomains bool + expectedValue string + }{ + { + name: "default settings", + maxAge: "31536000", + includeSubdomains: false, + expectedValue: "max-age=31536000", + }, + { + name: "with subdomains", + maxAge: "31536000", + includeSubdomains: true, + expectedValue: "max-age=31536000; includeSubDomains", + }, + { + name: "custom max-age", + maxAge: "86400", + includeSubdomains: false, + expectedValue: "max-age=86400", + }, + { + name: "custom max-age with subdomains", + maxAge: "604800", + includeSubdomains: true, + expectedValue: "max-age=604800; includeSubDomains", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildHSTS(tt.maxAge, tt.includeSubdomains) + if result != tt.expectedValue { + t.Errorf("Expected %q, got %q", tt.expectedValue, result) + } + }) + } +} diff --git a/pkg/i2gw/providers/nginx/annotations/listen_ports.go b/pkg/i2gw/providers/nginx/annotations/listen_ports.go new file mode 100644 index 000000000..c97ec912e --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/listen_ports.go @@ -0,0 +1,208 @@ +/* +Copyright 2025 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 annotations + +import ( + "fmt" + "strconv" + "strings" + + 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" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +type portConfiguration struct { + HTTP []int32 + HTTPS []int32 +} + +// ListenPortsFeature processes nginx.org/listen-ports and nginx.org/listen-ports-ssl annotations +func ListenPortsFeature(ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList + + ruleGroups := common.GetRuleGroups(ingresses) + for _, rg := range ruleGroups { + for _, rule := range rg.Rules { + httpPorts := extractListenPorts(rule.Ingress.Annotations[nginxListenPortsAnnotation]) + sslPorts := extractListenPorts(rule.Ingress.Annotations[nginxListenPortsSSLAnnotation]) + + config := portConfiguration{ + HTTP: httpPorts, + HTTPS: sslPorts, + } + if len(httpPorts) == 0 { + config.HTTP = []int32{80} // Default HTTP port + } + + if len(sslPorts) == 0 { + config.HTTPS = []int32{443} // Default HTTPS port + } + + if len(httpPorts) > 0 || len(sslPorts) > 0 { + errs = append(errs, replaceGatewayPortsWithCustom(rule.Ingress, config, ir)...) + } + } + } + + return errs +} + +// extractListenPorts parses comma-separated port numbers from annotation value +func extractListenPorts(portsAnnotation string) []int32 { + if portsAnnotation == "" { + return nil + } + + var ports []int32 + portStrings := splitAndTrimCommaList(portsAnnotation) + + for _, portStr := range portStrings { + if port, err := strconv.ParseInt(portStr, 10, 32); err == nil { + if port > 0 && port <= 65535 { + ports = append(ports, int32(port)) + } + } + } + + return ports +} + +// replaceGatewayPortsWithCustom modifies the Gateway to use ONLY the specified custom ports +// This follows NIC behavior where listen-ports annotations REPLACE default ports, not add to them +// +//nolint:unparam // ErrorList return type maintained for consistency +func replaceGatewayPortsWithCustom(ingress networkingv1.Ingress, portConfiguration portConfiguration, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList //nolint:unparam // ErrorList return type maintained for consistency + + gatewayClassName := getGatewayClassName(ingress) + gatewayKey := types.NamespacedName{Namespace: ingress.Namespace, Name: gatewayClassName} + + gatewayContext, exists := ir.Gateways[gatewayKey] + if !exists { + gatewayContext = intermediate.GatewayContext{ + Gateway: gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: gatewayClassName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName(gatewayClassName), + Listeners: []gatewayv1.Listener{}, + }, + }, + } + } + + var filteredListeners []gatewayv1.Listener + + for _, existingListener := range gatewayContext.Gateway.Spec.Listeners { + keep := true + for _, rule := range ingress.Spec.Rules { + hostname := rule.Host + if existingListener.Hostname != nil && string(*existingListener.Hostname) == hostname { + if existingListener.Port == 80 && existingListener.Protocol == gatewayv1.HTTPProtocolType { + keep = false + break + } + if existingListener.Port == 443 && existingListener.Protocol == gatewayv1.HTTPSProtocolType { + keep = false + break + } + } + } + if keep { + filteredListeners = append(filteredListeners, existingListener) + } + } + + for _, rule := range ingress.Spec.Rules { + hostname := rule.Host + + // Track used ports to avoid conflicts - HTTPS takes precedence over HTTP + usedPorts := make(map[int32]struct{}) + + // Add HTTPS listeners first (they take precedence) + for _, port := range portConfiguration.HTTPS { + listener := createListener(hostname, port, gatewayv1.HTTPSProtocolType) + + if len(ingress.Spec.TLS) > 0 { + listener.TLS = &gatewayv1.GatewayTLSConfig{ + Mode: common.PtrTo(gatewayv1.TLSModeTerminate), + CertificateRefs: []gatewayv1.SecretObjectReference{ + { + Name: gatewayv1.ObjectName(ingress.Spec.TLS[0].SecretName), + Namespace: (*gatewayv1.Namespace)(&ingress.Namespace), + }, + }, + } + } + + filteredListeners = append(filteredListeners, listener) + + usedPorts[port] = struct{}{} + } + + // Add HTTP listeners only if port not already used by HTTPS + for _, port := range portConfiguration.HTTP { + if _, exists := usedPorts[port]; !exists { + filteredListeners = append(filteredListeners, createListener(hostname, port, gatewayv1.HTTPProtocolType)) + } + } + } + + gatewayContext.Gateway.Spec.Listeners = filteredListeners + ir.Gateways[gatewayKey] = gatewayContext + return errs +} + +// createListener creates a Gateway listener for the given hostname, port, and protocol +func createListener(hostname string, port int32, protocol gatewayv1.ProtocolType) gatewayv1.Listener { + listenerName := createListenerName(hostname, port, protocol) + + listener := gatewayv1.Listener{ + Name: gatewayv1.SectionName(listenerName), + Port: gatewayv1.PortNumber(port), + Protocol: protocol, + } + + if hostname != "" { + listener.Hostname = (*gatewayv1.Hostname)(&hostname) + } + + return listener +} + +// createListenerName generates a safe listener name from hostname, port, and protocol +func createListenerName(hostname string, port int32, protocol gatewayv1.ProtocolType) string { + safeName := common.NameFromHost(hostname) + protocolStr := strings.ToLower(string(protocol)) + return fmt.Sprintf("%s-%s-%d", safeName, protocolStr, port) +} + +// getGatewayClassName extracts the gateway class name from ingress +func getGatewayClassName(ingress networkingv1.Ingress) string { + if ingress.Spec.IngressClassName != nil && *ingress.Spec.IngressClassName != "" { + return *ingress.Spec.IngressClassName + } + return NginxIngressClass +} diff --git a/pkg/i2gw/providers/nginx/annotations/listen_ports_test.go b/pkg/i2gw/providers/nginx/annotations/listen_ports_test.go new file mode 100644 index 000000000..6abfa447b --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/listen_ports_test.go @@ -0,0 +1,555 @@ +/* +Copyright 2025 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 annotations + +import ( + "reflect" + "testing" + + 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" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" +) + +func TestExtractListenPorts(t *testing.T) { + tests := []struct { + name string + annotation string + expected []int32 + }{ + { + name: "empty annotation", + annotation: "", + expected: nil, + }, + { + name: "single port", + annotation: "8080", + expected: []int32{8080}, + }, + { + name: "multiple ports", + annotation: "8080,9090,3000", + expected: []int32{8080, 9090, 3000}, + }, + { + name: "ports with spaces", + annotation: " 8080 , 9090 , 3000 ", + expected: []int32{8080, 9090, 3000}, + }, + { + name: "invalid ports filtered", + annotation: "8080,invalid,9090,0,65536", + expected: []int32{8080, 9090}, + }, + { + name: "empty parts filtered", + annotation: "8080,,9090,", + expected: []int32{8080, 9090}, + }, + { + name: "edge ports", + annotation: "1,65535", + expected: []int32{1, 65535}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractListenPorts(tt.annotation) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestCreateListenerName(t *testing.T) { + tests := []struct { + name string + hostname string + port int32 + protocol gatewayv1.ProtocolType + expected string + }{ + { + name: "HTTP listener", + hostname: "example.com", + port: 8080, + protocol: gatewayv1.HTTPProtocolType, + expected: "example-com-http-8080", + }, + { + name: "HTTPS listener", + hostname: "api.example.com", + port: 8443, + protocol: gatewayv1.HTTPSProtocolType, + expected: "api-example-com-https-8443", + }, + { + name: "empty hostname", + hostname: "", + port: 9090, + protocol: gatewayv1.HTTPProtocolType, + expected: "all-hosts-http-9090", + }, + { + name: "wildcard hostname", + hostname: "*", + port: 8080, + protocol: gatewayv1.HTTPProtocolType, + expected: "all-hosts-http-8080", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createListenerName(tt.hostname, tt.port, tt.protocol) + if result != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestCreateListener(t *testing.T) { + tests := []struct { + name string + hostname string + port int32 + protocol gatewayv1.ProtocolType + expected gatewayv1.Listener + }{ + { + name: "HTTP with hostname", + hostname: "example.com", + port: 8080, + protocol: gatewayv1.HTTPProtocolType, + expected: gatewayv1.Listener{ + Name: "example-com-http-8080", + Port: 8080, + Protocol: gatewayv1.HTTPProtocolType, + Hostname: (*gatewayv1.Hostname)(ptr.To("example.com")), + }, + }, + { + name: "HTTPS with hostname", + hostname: "secure.example.com", + port: 8443, + protocol: gatewayv1.HTTPSProtocolType, + expected: gatewayv1.Listener{ + Name: "secure-example-com-https-8443", + Port: 8443, + Protocol: gatewayv1.HTTPSProtocolType, + Hostname: (*gatewayv1.Hostname)(ptr.To("secure.example.com")), + }, + }, + { + name: "without hostname", + hostname: "", + port: 9090, + protocol: gatewayv1.HTTPProtocolType, + expected: gatewayv1.Listener{ + Name: "all-hosts-http-9090", + Port: 9090, + Protocol: gatewayv1.HTTPProtocolType, + Hostname: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := createListener(tt.hostname, tt.port, tt.protocol) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("Expected %+v, got %+v", tt.expected, result) + } + }) + } +} + +func TestListenPortsFeature(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expectedListeners int + expectedHTTPPorts []int32 + expectedSSLPorts []int32 + }{ + { + name: "no custom ports", + annotations: map[string]string{}, + expectedListeners: 0, + expectedHTTPPorts: nil, + expectedSSLPorts: nil, + }, + { + name: "custom HTTP ports only", + annotations: map[string]string{ + nginxListenPortsAnnotation: "8080,9090", + }, + expectedListeners: 3, + expectedHTTPPorts: []int32{8080, 9090}, + expectedSSLPorts: []int32{443}, + }, + { + name: "custom SSL ports only", + annotations: map[string]string{ + nginxListenPortsSSLAnnotation: "8443,9443", + }, + expectedListeners: 3, + expectedHTTPPorts: []int32{80}, + expectedSSLPorts: []int32{8443, 9443}, + }, + { + name: "both HTTP and SSL", + annotations: map[string]string{ + nginxListenPortsAnnotation: "8080,9090", + nginxListenPortsSSLAnnotation: "8443,9443", + }, + expectedListeners: 4, + expectedHTTPPorts: []int32{8080, 9090}, + expectedSSLPorts: []int32{8443, 9443}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: tt.annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + ir := intermediate.IR{ + Gateways: make(map[types.NamespacedName]intermediate.GatewayContext), + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + + errs := ListenPortsFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + if tt.expectedListeners == 0 { + if len(ir.Gateways) > 0 { + t.Error("Expected no gateways to be created") + } + return + } + + if len(ir.Gateways) != 1 { + t.Errorf("Expected 1 gateway, got %d", len(ir.Gateways)) + return + } + + var gateway gatewayv1.Gateway + for _, gwContext := range ir.Gateways { + gateway = gwContext.Gateway + break + } + + if len(gateway.Spec.Listeners) != tt.expectedListeners { + t.Errorf("Expected %d listeners, got %d", tt.expectedListeners, len(gateway.Spec.Listeners)) + return + } + + httpCount := 0 + for _, listener := range gateway.Spec.Listeners { + if listener.Protocol == gatewayv1.HTTPProtocolType { + httpCount++ + found := false + for _, expectedPort := range tt.expectedHTTPPorts { + if int32(listener.Port) == expectedPort { + found = true + break + } + } + if !found { + t.Errorf("Unexpected HTTP port %d", listener.Port) + } + } + } + + if httpCount != len(tt.expectedHTTPPorts) { + t.Errorf("Expected %d HTTP listeners, got %d", len(tt.expectedHTTPPorts), httpCount) + } + + sslCount := 0 + for _, listener := range gateway.Spec.Listeners { + if listener.Protocol == gatewayv1.HTTPSProtocolType { + sslCount++ + found := false + for _, expectedPort := range tt.expectedSSLPorts { + if int32(listener.Port) == expectedPort { + found = true + break + } + } + if !found { + t.Errorf("Unexpected HTTPS port %d", listener.Port) + } + } + } + + if sslCount != len(tt.expectedSSLPorts) { + t.Errorf("Expected %d HTTPS listeners, got %d", len(tt.expectedSSLPorts), sslCount) + } + + for _, listener := range gateway.Spec.Listeners { + if listener.Hostname == nil || string(*listener.Hostname) != "example.com" { + t.Errorf("Expected hostname 'example.com', got %v", listener.Hostname) + } + } + }) + } +} + +func TestListenPortsReplacesDefaultListeners(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + nginxListenPortsAnnotation: "8080,9090", + nginxListenPortsSSLAnnotation: "8443", + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Start with IR that has a Gateway with default listeners (simulating what common converter creates) + gatewayKey := types.NamespacedName{Namespace: "default", Name: "nginx"} + ir := intermediate.IR{ + Gateways: map[types.NamespacedName]intermediate.GatewayContext{ + gatewayKey: { + Gateway: gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx", + Namespace: "default", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "nginx", + Listeners: []gatewayv1.Listener{ + { + Name: "example-com-http", + Hostname: (*gatewayv1.Hostname)(ptr.To("example.com")), + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + }, + { + Name: "example-com-https", + Hostname: (*gatewayv1.Hostname)(ptr.To("example.com")), + Port: 443, + Protocol: gatewayv1.HTTPSProtocolType, + }, + }, + }, + }, + }, + }, + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + + // Apply listen-ports feature + errs := ListenPortsFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + // Verify Gateway was updated + gateway, exists := ir.Gateways[gatewayKey] + if !exists { + t.Error("Gateway should exist") + return + } + + // Verify expected custom ports are present + expectedPorts := map[int32]gatewayv1.ProtocolType{ + 8080: gatewayv1.HTTPProtocolType, + 9090: gatewayv1.HTTPProtocolType, + 8443: gatewayv1.HTTPSProtocolType, + } + + foundPorts := make(map[int32]gatewayv1.ProtocolType) + for _, listener := range gateway.Gateway.Spec.Listeners { + foundPorts[int32(listener.Port)] = listener.Protocol + + // Verify hostname is set correctly + if listener.Hostname == nil || string(*listener.Hostname) != "example.com" { + t.Errorf("Expected hostname 'example.com', got %v", listener.Hostname) + } + } + + for expectedPort, expectedProtocol := range expectedPorts { + if foundProtocol, exists := foundPorts[expectedPort]; !exists { + t.Errorf("Expected port %d not found", expectedPort) + } else if foundProtocol != expectedProtocol { + t.Errorf("Expected protocol %s for port %d, got %s", expectedProtocol, expectedPort, foundProtocol) + } + } +} + +func TestListenPortsConflictResolution(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conflict", + Namespace: "default", + Annotations: map[string]string{ + nginxListenPortsAnnotation: "8080,8443,9090", + nginxListenPortsSSLAnnotation: "8443,9443", + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + ir := intermediate.IR{ + Gateways: make(map[types.NamespacedName]intermediate.GatewayContext), + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + + errs := ListenPortsFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + if len(ir.Gateways) != 1 { + t.Errorf("Expected 1 gateway, got %d", len(ir.Gateways)) + return + } + + var gateway gatewayv1.Gateway + for _, gwContext := range ir.Gateways { + gateway = gwContext.Gateway + break + } + + // Should have 4 listeners: 8080 HTTP, 9090 HTTP, 8443 HTTPS, 9443 HTTPS + // Note: 8443 should be HTTPS only (not HTTP) due to conflict resolution + expectedListeners := 4 + if len(gateway.Spec.Listeners) != expectedListeners { + t.Errorf("Expected %d listeners, got %d", expectedListeners, len(gateway.Spec.Listeners)) + return + } + + // Verify no port conflicts (same port with different protocols) + portProtocols := make(map[int32][]gatewayv1.ProtocolType) + for _, listener := range gateway.Spec.Listeners { + port := int32(listener.Port) + portProtocols[port] = append(portProtocols[port], listener.Protocol) + } + + for port, protocols := range portProtocols { + if len(protocols) > 1 { + t.Errorf("Port %d has conflicting protocols: %v", port, protocols) + } + } + + // Verify specific expected configurations + expectedConfigs := map[int32]gatewayv1.ProtocolType{ + 8080: gatewayv1.HTTPProtocolType, // HTTP only + 9090: gatewayv1.HTTPProtocolType, // HTTP only + 8443: gatewayv1.HTTPSProtocolType, // HTTPS takes precedence over HTTP + 9443: gatewayv1.HTTPSProtocolType, // HTTPS only + } + + foundConfigs := make(map[int32]gatewayv1.ProtocolType) + for _, listener := range gateway.Spec.Listeners { + foundConfigs[int32(listener.Port)] = listener.Protocol + } + + for expectedPort, expectedProtocol := range expectedConfigs { + if foundProtocol, exists := foundConfigs[expectedPort]; !exists { + t.Errorf("Expected port %d not found", expectedPort) + } else if foundProtocol != expectedProtocol { + t.Errorf("Expected protocol %s for port %d, got %s", expectedProtocol, expectedPort, foundProtocol) + } + } +} diff --git a/pkg/i2gw/providers/nginx/annotations/notification.go b/pkg/i2gw/providers/nginx/annotations/notification.go new file mode 100644 index 000000000..fd15caefb --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/notification.go @@ -0,0 +1,29 @@ +/* +Copyright 2025 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 annotations + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/notifications" +) + +// notify dispatches a notification with the nginx provider name +func notify(mType notifications.MessageType, message string, callingObject ...client.Object) { + newNotification := notifications.NewNotification(mType, message, callingObject...) + notifications.NotificationAggr.DispatchNotification(newNotification, "nginx") +} diff --git a/pkg/i2gw/providers/nginx/annotations/path_matching.go b/pkg/i2gw/providers/nginx/annotations/path_matching.go new file mode 100644 index 000000000..4ad2417ca --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/path_matching.go @@ -0,0 +1,109 @@ +/* +Copyright 2025 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 annotations + +import ( + "strings" + + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/notifications" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +// PathRegexFeature converts nginx.org/path-regex annotation to regex path matching +func PathRegexFeature(ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList + + // Valid values for path-regex annotation + var validPathRegexValues = map[string]struct{}{ + "true": {}, + "case_sensitive": {}, + "case_insensitive": {}, + "exact": {}, + } + + ruleGroups := common.GetRuleGroups(ingresses) + for _, rg := range ruleGroups { + for _, rule := range rg.Rules { + pathRegex, exists := rule.Ingress.Annotations[nginxPathRegexAnnotation] + if !exists || pathRegex == "" { + continue + } + + if _, valid := validPathRegexValues[pathRegex]; !valid { + continue + } + + // Determine the appropriate path match type based on the annotation value + var pathMatchType gatewayv1.PathMatchType + if pathRegex == "exact" { + pathMatchType = gatewayv1.PathMatchExact + } else { + // "true", "case_sensitive", "case_insensitive" all use regex + pathMatchType = gatewayv1.PathMatchRegularExpression + + // Add a general warning about NGF not supporting regex + message := "nginx.org/path-regex: PathMatchRegularExpression is not supported by NGINX Gateway Fabric - only Exact and PathPrefix are supported" + notify(notifications.WarningNotification, message, &rule.Ingress) + + // Add a warning for case_insensitive since Gateway API doesn't guarantee it + if pathRegex == "case_insensitive" { + message := "nginx.org/path-regex: case_insensitive - injected (?i) regex flag but case insensitive behavior depends on Gateway implementation support" + notify(notifications.WarningNotification, message, &rule.Ingress) + } + } + + // Get the HTTPRoute for this rule group + key := types.NamespacedName{Namespace: rule.Ingress.Namespace, Name: common.RouteName(rg.Name, rg.Host)} + httpRouteContext, ok := ir.HTTPRoutes[key] + if !ok { + continue + } + + for _, routeRule := range httpRouteContext.HTTPRoute.Spec.Rules { + for _, match := range routeRule.Matches { + if match.Path != nil { + match.Path.Type = ptr.To(pathMatchType) + + // For case_insensitive regex, inject (?i) flag at the beginning + if pathRegex == "case_insensitive" && pathMatchType == gatewayv1.PathMatchRegularExpression { + if match.Path.Value != nil { + originalPath := *match.Path.Value + // Only inject if not already present + if !strings.HasPrefix(originalPath, "(?i)") { + caseInsensitivePath := "(?i)" + originalPath + match.Path.Value = &caseInsensitivePath + } + } + } + } + } + } + + // Update the HTTPRoute in the IR + ir.HTTPRoutes[key] = httpRouteContext + } + } + + return errs +} diff --git a/pkg/i2gw/providers/nginx/annotations/path_matching_test.go b/pkg/i2gw/providers/nginx/annotations/path_matching_test.go new file mode 100644 index 000000000..b7a3eec3c --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/path_matching_test.go @@ -0,0 +1,588 @@ +/* +Copyright 2025 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 annotations + +import ( + "testing" + + 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" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +func TestPathRegex(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expectedPathType gatewayv1.PathMatchType + expectedPathValue string + shouldModifyMatches bool + }{ + { + name: "true enables regex", + annotations: map[string]string{ + "nginx.org/path-regex": "true", + }, + expectedPathType: gatewayv1.PathMatchRegularExpression, + expectedPathValue: "/api/.*", + shouldModifyMatches: true, + }, + { + name: "case_sensitive enables regex", + annotations: map[string]string{ + "nginx.org/path-regex": "case_sensitive", + }, + expectedPathType: gatewayv1.PathMatchRegularExpression, + expectedPathValue: "/api/.*", + shouldModifyMatches: true, + }, + { + name: "case_insensitive enables regex", + annotations: map[string]string{ + "nginx.org/path-regex": "case_insensitive", + }, + expectedPathType: gatewayv1.PathMatchRegularExpression, + expectedPathValue: "(?i)/api/.*", + shouldModifyMatches: true, + }, + { + name: "exact enables exact matching", + annotations: map[string]string{ + "nginx.org/path-regex": "exact", + }, + expectedPathType: gatewayv1.PathMatchExact, + expectedPathValue: "/api/.*", + shouldModifyMatches: true, + }, + { + name: "false disables regex", + annotations: map[string]string{ + "nginx.org/path-regex": "false", + }, + expectedPathType: gatewayv1.PathMatchPathPrefix, + expectedPathValue: "/api/.*", + shouldModifyMatches: false, + }, + { + name: "missing annotation disables regex", + annotations: map[string]string{ + "nginx.org/rewrites": "service=/api", + }, + expectedPathType: gatewayv1.PathMatchPathPrefix, + expectedPathValue: "/api/.*", + shouldModifyMatches: false, + }, + { + name: "no annotations disables regex", + annotations: map[string]string{}, + expectedPathType: gatewayv1.PathMatchPathPrefix, + expectedPathValue: "/api/.*", + shouldModifyMatches: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: tt.annotations, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/api/.*", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "api-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + ir := intermediate.IR{ + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + + httpRoute := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: ptr.To("/api/.*"), + }, + }, + }, + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName("api-service"), + Kind: ptr.To(gatewayv1.Kind("Service")), + Group: ptr.To(gatewayv1.Group("")), + Port: ptr.To(gatewayv1.PortNumber(80)), + }, + }, + }, + }, + }, + }, + }, + } + + ir.HTTPRoutes[routeKey] = intermediate.HTTPRouteContext{ + HTTPRoute: httpRoute, + } + + errs := PathRegexFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + updatedRoute := ir.HTTPRoutes[routeKey] + if len(updatedRoute.HTTPRoute.Spec.Rules) == 0 || len(updatedRoute.HTTPRoute.Spec.Rules[0].Matches) == 0 { + t.Error("Expected HTTPRoute to have rules and matches") + return + } + + match := updatedRoute.HTTPRoute.Spec.Rules[0].Matches[0] + if match.Path == nil { + t.Error("Expected path match to exist") + return + } + + actualPathType := *match.Path.Type + if actualPathType != tt.expectedPathType { + t.Errorf("Expected path type %v, got %v", tt.expectedPathType, actualPathType) + } + + if *match.Path.Value != tt.expectedPathValue { + t.Errorf("Expected path value %v, got %v", tt.expectedPathValue, *match.Path.Value) + } + }) + } +} + +func TestPathRegexMultipleMatches(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multi-paths", + Namespace: "default", + Annotations: map[string]string{ + "nginx.org/path-regex": "true", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/api/v1/.*", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "api-v1-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + { + Path: "/api/v2/.*", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "api-v2-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + ir := intermediate.IR{ + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + + httpRoute := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: ptr.To("/api/v1/.*"), + }, + }, + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: ptr.To("/api/v2/.*"), + }, + }, + }, + }, + }, + }, + } + + ir.HTTPRoutes[routeKey] = intermediate.HTTPRouteContext{ + HTTPRoute: httpRoute, + } + + errs := PathRegexFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + updatedRoute := ir.HTTPRoutes[routeKey] + matches := updatedRoute.HTTPRoute.Spec.Rules[0].Matches + + if len(matches) != 2 { + t.Errorf("Expected 2 matches, got %d", len(matches)) + return + } + + for i, match := range matches { + if match.Path == nil { + t.Errorf("Expected path match %d to exist", i) + return + } + + if *match.Path.Type != gatewayv1.PathMatchRegularExpression { + t.Errorf("Expected match %d to have RegularExpression type, got %v", i, *match.Path.Type) + } + } +} + +func TestPathRegexCaseInsensitiveNotification(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-case-insensitive", + Namespace: "default", + Annotations: map[string]string{ + "nginx.org/path-regex": "case_insensitive", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/api/.*", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "api-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + ir := intermediate.IR{ + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + + httpRoute := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: ptr.To("/api/.*"), + }, + }, + }, + }, + }, + }, + } + + ir.HTTPRoutes[routeKey] = intermediate.HTTPRouteContext{ + HTTPRoute: httpRoute, + } + + errs := PathRegexFeature([]networkingv1.Ingress{ingress}, nil, &ir) + + // Should have no errors since we're using notifications now + if len(errs) != 0 { + t.Errorf("Expected 0 errors, got %d", len(errs)) + return + } + + // Verify path type is still set correctly + updatedRoute := ir.HTTPRoutes[routeKey] + if *updatedRoute.HTTPRoute.Spec.Rules[0].Matches[0].Path.Type != gatewayv1.PathMatchRegularExpression { + t.Errorf("Expected path type to be PathMatchRegularExpression") + } + + // Note: Testing notifications requires access to the notification aggregator, + // which is more complex to test in unit tests. The notification dispatch + // is tested through integration tests. +} + +func TestPathRegexCaseInsensitiveFlagInjection(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + nginxPathRegexAnnotation: "case_insensitive", + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/api/v[0-9]+", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "api-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Create HTTPRoute first (simulating what common converter creates) + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + + originalPath := "/api/v[0-9]+" + ir := intermediate.IR{ + HTTPRoutes: map[types.NamespacedName]intermediate.HTTPRouteContext{ + routeKey: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: &originalPath, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Apply path regex feature + errs := PathRegexFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + // Verify the path was modified + updatedRoute := ir.HTTPRoutes[routeKey] + if len(updatedRoute.HTTPRoute.Spec.Rules) == 0 { + t.Error("Expected HTTPRoute to have rules") + return + } + + rule := updatedRoute.HTTPRoute.Spec.Rules[0] + if len(rule.Matches) == 0 { + t.Error("Expected HTTPRoute rule to have matches") + return + } + + match := rule.Matches[0] + if match.Path == nil { + t.Error("Expected HTTPRoute match to have path") + return + } + + // Verify path match type is regular expression + if match.Path.Type == nil || *match.Path.Type != gatewayv1.PathMatchRegularExpression { + t.Errorf("Expected PathMatchRegularExpression, got %v", match.Path.Type) + } + + if match.Path.Value == nil { + t.Error("Expected path value to be set") + return + } + + // Verify (?i) flag was injected + expectedPath := "(?i)/api/v[0-9]+" + if *match.Path.Value != expectedPath { + t.Errorf("Expected path value '%s', got '%s'", expectedPath, *match.Path.Value) + } +} + +func TestPathRegexCaseInsensitiveFlagNotDuplicated(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + nginxPathRegexAnnotation: "case_insensitive", + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/api/v[0-9]+", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "api-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Create HTTPRoute with path that already has (?i) flag + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + + originalPath := "(?i)/api/v[0-9]+" + ir := intermediate.IR{ + HTTPRoutes: map[types.NamespacedName]intermediate.HTTPRouteContext{ + routeKey: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: &originalPath, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Apply path regex feature + errs := PathRegexFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Fatalf("Unexpected errors: %v", errs) + } + + // Verify the path was NOT duplicated + updatedRoute := ir.HTTPRoutes[routeKey] + match := updatedRoute.HTTPRoute.Spec.Rules[0].Matches[0] + + // Should still be the original path, not (?i)(?i)/api/v[0-9]+ + expectedPath := "(?i)/api/v[0-9]+" + if *match.Path.Value != expectedPath { + t.Errorf("Expected path value '%s', got '%s'", expectedPath, *match.Path.Value) + } +} diff --git a/pkg/i2gw/providers/nginx/annotations/path_rewrite.go b/pkg/i2gw/providers/nginx/annotations/path_rewrite.go new file mode 100644 index 000000000..48902ee02 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/path_rewrite.go @@ -0,0 +1,120 @@ +/* +Copyright 2025 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 annotations + +import ( + "strings" + + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +// RewriteTargetFeature converts nginx.org/rewrites annotation to URLRewrite filter +func RewriteTargetFeature(ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList + + ruleGroups := common.GetRuleGroups(ingresses) + for _, rg := range ruleGroups { + for _, rule := range rg.Rules { + rewriteValue, exists := rule.Ingress.Annotations[nginxRewritesAnnotation] + if !exists || rewriteValue == "" { + continue + } + + rewriteRules := parseRewriteRules(rewriteValue) + if len(rewriteRules) == 0 { + continue + } + + // Get the HTTPRoute for this rule group + key := types.NamespacedName{Namespace: rule.Ingress.Namespace, Name: common.RouteName(rg.Name, rg.Host)} + httpRouteContext, ok := ir.HTTPRoutes[key] + if !ok { + continue + } + + for i := range httpRouteContext.HTTPRoute.Spec.Rules { + for _, path := range rule.IngressRule.HTTP.Paths { + serviceName := path.Backend.Service.Name + if rewritePath, hasRewrite := rewriteRules[serviceName]; hasRewrite { + filter := gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterURLRewrite, + URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ + Path: &gatewayv1.HTTPPathModifier{ + Type: gatewayv1.PrefixMatchHTTPPathModifier, + ReplacePrefixMatch: ptr.To(rewritePath), + }, + }, + } + + if httpRouteContext.HTTPRoute.Spec.Rules[i].Filters == nil { + httpRouteContext.HTTPRoute.Spec.Rules[i].Filters = []gatewayv1.HTTPRouteFilter{} + } + httpRouteContext.HTTPRoute.Spec.Rules[i].Filters = append(httpRouteContext.HTTPRoute.Spec.Rules[i].Filters, filter) + } + } + } + + // Update the HTTPRoute in the IR + ir.HTTPRoutes[key] = httpRouteContext + } + } + + return errs +} + +// parseRewriteRules parses nginx.org/rewrites annotation format +// NIC format: "serviceName=service rewrite=path;serviceName2=service2 rewrite=path2" +func parseRewriteRules(rewriteValue string) map[string]string { + rules := make(map[string]string) + + if rewriteValue == "" { + return rules + } + + // Split by semicolon for each rule + parts := strings.Split(rewriteValue, ";") + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + // Expect format: serviceName=service rewrite=rewrite + serviceIdx := strings.Index(part, "=") + rewriteIdx := strings.Index(part, " rewrite=") + if serviceIdx == -1 || rewriteIdx == -1 || rewriteIdx <= serviceIdx { + continue + } + + serviceName := strings.TrimSpace(part[serviceIdx+1 : rewriteIdx]) + rewritePath := strings.TrimSpace(part[rewriteIdx+9:]) + + if serviceName != "" && rewritePath != "" { + rules[serviceName] = rewritePath + } + } + + return rules +} diff --git a/pkg/i2gw/providers/nginx/annotations/path_rewrite_test.go b/pkg/i2gw/providers/nginx/annotations/path_rewrite_test.go new file mode 100644 index 000000000..6ec32d854 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/path_rewrite_test.go @@ -0,0 +1,256 @@ +/* +Copyright 2025 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 annotations + +import ( + "testing" + + 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" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +func TestRewriteTarget(t *testing.T) { + tests := []struct { + name string + ingress networkingv1.Ingress + expectedFilter *gatewayv1.HTTPRouteFilter + }{ + { + name: "simple format", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + "nginx.org/rewrites": "serviceName=web-service rewrite=/api/v1", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/app", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedFilter: &gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterURLRewrite, + URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ + Path: &gatewayv1.HTTPPathModifier{ + Type: gatewayv1.PrefixMatchHTTPPathModifier, + ReplacePrefixMatch: ptr.To("/api/v1"), + }, + }, + }, + }, + { + name: "NIC format", + ingress: networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-nic", + Namespace: "default", + Annotations: map[string]string{ + "nginx.org/rewrites": "serviceName=coffee rewrite=/coffee", + }, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + Host: "coffee.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/app", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "coffee", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedFilter: &gatewayv1.HTTPRouteFilter{ + Type: gatewayv1.HTTPRouteFilterURLRewrite, + URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ + Path: &gatewayv1.HTTPPathModifier{ + Type: gatewayv1.PrefixMatchHTTPPathModifier, + ReplacePrefixMatch: ptr.To("/coffee"), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ir := intermediate.IR{ + HTTPRoutes: make(map[types.NamespacedName]intermediate.HTTPRouteContext), + } + + routeName := common.RouteName(tt.ingress.Name, tt.ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: tt.ingress.Namespace, Name: routeName} + + httpRoute := gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: tt.ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: ptr.To(gatewayv1.PathMatchPathPrefix), + Value: ptr.To("/app"), + }, + }, + }, + }, + }, + }, + } + + ir.HTTPRoutes[routeKey] = intermediate.HTTPRouteContext{ + HTTPRoute: httpRoute, + } + + errs := RewriteTargetFeature([]networkingv1.Ingress{tt.ingress}, nil, &ir) + if len(errs) > 0 { + t.Errorf("Unexpected errors: %v", errs) + return + } + + updatedRoute := ir.HTTPRoutes[routeKey] + if len(updatedRoute.HTTPRoute.Spec.Rules) == 0 || len(updatedRoute.HTTPRoute.Spec.Rules[0].Filters) == 0 { + t.Errorf("Expected filter to be added to HTTPRoute") + return + } + + filter := updatedRoute.HTTPRoute.Spec.Rules[0].Filters[0] + if filter.Type != tt.expectedFilter.Type { + t.Errorf("Expected filter type %v, got %v", tt.expectedFilter.Type, filter.Type) + } + + if filter.URLRewrite == nil || filter.URLRewrite.Path == nil { + t.Errorf("Expected URLRewrite filter with Path modifier") + return + } + + if *filter.URLRewrite.Path.ReplacePrefixMatch != *tt.expectedFilter.URLRewrite.Path.ReplacePrefixMatch { + t.Errorf("Expected rewrite path %v, got %v", + *tt.expectedFilter.URLRewrite.Path.ReplacePrefixMatch, + *filter.URLRewrite.Path.ReplacePrefixMatch) + } + }) + } +} + +func TestParseRewriteRules(t *testing.T) { + tests := []struct { + name string + input string + expectedRules map[string]string + }{ + { + name: "single rule", + input: "serviceName=coffee rewrite=/coffee", + expectedRules: map[string]string{ + "coffee": "/coffee", + }, + }, + { + name: "multiple rules", + input: "serviceName=coffee rewrite=/coffee;serviceName=tea rewrite=/tea", + expectedRules: map[string]string{ + "coffee": "/coffee", + "tea": "/tea", + }, + }, + { + name: "rules with spaces", + input: "serviceName=coffee rewrite=/coffee ; serviceName=tea rewrite=/tea ", + expectedRules: map[string]string{ + "coffee": "/coffee", + "tea": "/tea", + }, + }, + { + name: "empty input", + input: "", + expectedRules: map[string]string{}, + }, + { + name: "invalid format", + input: "invalid-rule-without-equals", + expectedRules: map[string]string{}, + }, + { + name: "complex path", + input: "serviceName=api-service rewrite=/api/v2/users", + expectedRules: map[string]string{ + "api-service": "/api/v2/users", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseRewriteRules(tt.input) + + if len(result) != len(tt.expectedRules) { + t.Errorf("Expected %d rules, got %d", len(tt.expectedRules), len(result)) + } + + for expectedService, expectedPath := range tt.expectedRules { + if actualPath, exists := result[expectedService]; !exists { + t.Errorf("Expected service %s not found in result", expectedService) + } else if actualPath != expectedPath { + t.Errorf("Expected path %s for service %s, got %s", expectedPath, expectedService, actualPath) + } + } + }) + } +} diff --git a/pkg/i2gw/providers/nginx/annotations/ssl_redirect.go b/pkg/i2gw/providers/nginx/annotations/ssl_redirect.go new file mode 100644 index 000000000..f0179d19e --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/ssl_redirect.go @@ -0,0 +1,121 @@ +/* +Copyright 2025 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 annotations + +import ( + "fmt" + "strings" + + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +// SSLRedirectFeature converts SSL redirect annotations to Gateway API RequestRedirect filters. +// Both nginx.org/redirect-to-https and ingress.kubernetes.io/ssl-redirect function identically. +func SSLRedirectFeature(ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList + + ruleGroups := common.GetRuleGroups(ingresses) + for _, rg := range ruleGroups { + for _, rule := range rg.Rules { + modernRedirect, modernExists := rule.Ingress.Annotations[nginxRedirectToHTTPSAnnotation] + legacyRedirect, legacyExists := rule.Ingress.Annotations[legacySSLRedirectAnnotation] + + // Check if either SSL redirect annotation is enabled + if !((modernExists && modernRedirect == "true") || (legacyExists && legacyRedirect == "true")) { + continue + } + + for _, ingressRule := range rule.Ingress.Spec.Rules { + ensureHTTPSListener(rule.Ingress, ingressRule, ir) + + routeName := common.RouteName(rule.Ingress.Name, ingressRule.Host) + routeKey := types.NamespacedName{Namespace: rule.Ingress.Namespace, Name: routeName} + httpRouteContext, routeExists := ir.HTTPRoutes[routeKey] + if !routeExists { + continue + } + + // Update parentRefs to specify the HTTP listener for SSL redirect + httpListenerName := fmt.Sprintf("%s-http", strings.ReplaceAll(ingressRule.Host, ".", "-")) + for i := range httpRouteContext.HTTPRoute.Spec.ParentRefs { + httpRouteContext.HTTPRoute.Spec.ParentRefs[i].SectionName = (*gatewayv1.SectionName)(&httpListenerName) + } + + // Add redirect rule at the beginning to redirect all HTTP traffic to HTTPS + redirectRule := gatewayv1.HTTPRouteRule{ + Filters: []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestRedirect, + RequestRedirect: &gatewayv1.HTTPRequestRedirectFilter{ + Scheme: ptr.To("https"), + StatusCode: ptr.To(301), + }, + }, + }, + } + httpRouteContext.HTTPRoute.Spec.Rules = append([]gatewayv1.HTTPRouteRule{redirectRule}, httpRouteContext.HTTPRoute.Spec.Rules...) + + ir.HTTPRoutes[routeKey] = httpRouteContext + } + } + } + + return errs +} + +// ensureHTTPSListener ensures that a Gateway resource has an HTTPS listener configured +// for the specified Ingress rule. If it doesn't, one is created. +func ensureHTTPSListener(ingress networkingv1.Ingress, rule networkingv1.IngressRule, ir *intermediate.IR) { + gatewayName := NginxIngressClass + if ingress.Spec.IngressClassName != nil { + gatewayName = *ingress.Spec.IngressClassName + } + gatewayKey := types.NamespacedName{Namespace: ingress.Namespace, Name: gatewayName} + gatewayContext, exists := ir.Gateways[gatewayKey] + if !exists { + return + } + + hostname := gatewayv1.Hostname(rule.Host) + for _, listener := range gatewayContext.Gateway.Spec.Listeners { + if listener.Protocol == gatewayv1.HTTPSProtocolType && (listener.Hostname == nil || *listener.Hostname == hostname) { + return + } + } + + httpsListener := gatewayv1.Listener{ + Name: gatewayv1.SectionName(fmt.Sprintf("https-%s", strings.ReplaceAll(rule.Host, ".", "-"))), + Protocol: gatewayv1.HTTPSProtocolType, + Port: 443, + Hostname: &hostname, + TLS: &gatewayv1.GatewayTLSConfig{ + Mode: ptr.To(gatewayv1.TLSModeTerminate), + CertificateRefs: []gatewayv1.SecretObjectReference{ + {Name: gatewayv1.ObjectName(fmt.Sprintf("%s-tls", strings.ReplaceAll(rule.Host, ".", "-")))}, + }, + }, + } + gatewayContext.Gateway.Spec.Listeners = append(gatewayContext.Gateway.Spec.Listeners, httpsListener) + ir.Gateways[gatewayKey] = gatewayContext +} diff --git a/pkg/i2gw/providers/nginx/annotations/ssl_redirect_test.go b/pkg/i2gw/providers/nginx/annotations/ssl_redirect_test.go new file mode 100644 index 000000000..8c68ed01a --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/ssl_redirect_test.go @@ -0,0 +1,222 @@ +/* +Copyright 2025 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 annotations + +import ( + "testing" + + 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" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +func TestSSLRedirectFeature(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expectRedirect bool + }{ + { + name: "modern NGINX redirect annotation", + annotations: map[string]string{ + nginxRedirectToHTTPSAnnotation: "true", + }, + expectRedirect: true, + }, + { + name: "legacy SSL redirect annotation", + annotations: map[string]string{ + legacySSLRedirectAnnotation: "true", + }, + expectRedirect: true, + }, + { + name: "no annotations", + annotations: map[string]string{}, + expectRedirect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: tt.annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Setup IR with existing Gateway and HTTPRoute + routeName := common.RouteName(ingress.Name, ingress.Spec.Rules[0].Host) + routeKey := types.NamespacedName{Namespace: ingress.Namespace, Name: routeName} + gatewayKey := types.NamespacedName{Namespace: ingress.Namespace, Name: "nginx"} + + ir := intermediate.IR{ + Gateways: map[types.NamespacedName]intermediate.GatewayContext{ + gatewayKey: { + Gateway: gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx", + Namespace: "default", + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "nginx", + Listeners: []gatewayv1.Listener{ + { + Name: "http", + Port: 80, + Protocol: gatewayv1.HTTPProtocolType, + }, + }, + }, + }, + }, + }, + HTTPRoutes: map[types.NamespacedName]intermediate.HTTPRouteContext{ + routeKey: { + HTTPRoute: gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: routeName, + Namespace: ingress.Namespace, + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{ + { + Name: "nginx", + }, + }, + }, + Rules: []gatewayv1.HTTPRouteRule{ + { + BackendRefs: []gatewayv1.HTTPBackendRef{ + { + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: "web-service", + Port: ptr.To(gatewayv1.PortNumber(80)), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // Execute + errs := SSLRedirectFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Errorf("Unexpected errors: %v", errs) + return + } + + // Verify results + if !tt.expectRedirect { + // Should not have added HTTPS listener + gateway := ir.Gateways[gatewayKey].Gateway + httpsListeners := 0 + for _, listener := range gateway.Spec.Listeners { + if listener.Protocol == gatewayv1.HTTPSProtocolType { + httpsListeners++ + } + } + if httpsListeners > 0 { + t.Errorf("Expected no HTTPS listeners, got %d", httpsListeners) + } + return + } + + // Verify HTTPS listener was added + gateway := ir.Gateways[gatewayKey].Gateway + httpsListenerFound := false + for _, listener := range gateway.Spec.Listeners { + if listener.Protocol == gatewayv1.HTTPSProtocolType { + httpsListenerFound = true + break + } + } + if !httpsListenerFound { + t.Error("Expected HTTPS listener to be added") + } + + // Verify HTTPRoute modifications + httpRoute := ir.HTTPRoutes[routeKey].HTTPRoute + + // Verify parentRefs sectionName is set + if len(httpRoute.Spec.ParentRefs) == 0 || httpRoute.Spec.ParentRefs[0].SectionName == nil { + t.Error("Expected parentRefs sectionName to be set") + } + + // Verify redirect rule was added + if len(httpRoute.Spec.Rules) < 2 { + t.Errorf("Expected at least 2 rules (redirect + original)") + return + } + + // First rule should be the redirect rule + redirectRule := httpRoute.Spec.Rules[0] + if len(redirectRule.Filters) == 0 || redirectRule.Filters[0].Type != gatewayv1.HTTPRouteFilterRequestRedirect { + t.Error("Expected RequestRedirect filter in first rule") + } + + // Verify redirect filter configuration + if redirectRule.Filters[0].RequestRedirect == nil { + t.Error("Expected RequestRedirect to be configured") + } else { + if redirectRule.Filters[0].RequestRedirect.Scheme == nil || *redirectRule.Filters[0].RequestRedirect.Scheme != "https" { + t.Error("Expected redirect scheme to be 'https'") + } + if redirectRule.Filters[0].RequestRedirect.StatusCode == nil || *redirectRule.Filters[0].RequestRedirect.StatusCode != 301 { + t.Error("Expected redirect status code to be 301") + } + } + }) + } +} diff --git a/pkg/i2gw/providers/nginx/annotations/ssl_services.go b/pkg/i2gw/providers/nginx/annotations/ssl_services.go new file mode 100644 index 000000000..0f3b92f8a --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/ssl_services.go @@ -0,0 +1,83 @@ +/* +Copyright 2025 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 annotations + +import ( + "fmt" + + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + gatewayv1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/notifications" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +// SSLServicesFeature processes nginx.org/ssl-services annotation +func SSLServicesFeature(ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList + + for _, ingress := range ingresses { + if sslServices, exists := ingress.Annotations[nginxSSLServicesAnnotation]; exists && sslServices != "" { + errs = append(errs, processSSLServicesAnnotation(ingress, sslServices, ir)...) + } + } + + return errs +} + +// processSSLServicesAnnotation configures HTTPS backend protocol using BackendTLSPolicy +// +//nolint:unparam // ErrorList return type maintained for consistency +func processSSLServicesAnnotation(ingress networkingv1.Ingress, sslServices string, ir *intermediate.IR) field.ErrorList { + var errs field.ErrorList //nolint:unparam // ErrorList return type maintained for consistency + + services := splitAndTrimCommaList(sslServices) + sslServiceSet := make(map[string]struct{}) + for _, service := range services { + sslServiceSet[service] = struct{}{} + } + + if ir.BackendTLSPolicies == nil { + ir.BackendTLSPolicies = make(map[types.NamespacedName]gatewayv1alpha3.BackendTLSPolicy) + } + for serviceName := range sslServiceSet { + policyName := BackendTLSPolicyName(ingress.Name, serviceName) + policy := common.CreateBackendTLSPolicy(ingress.Namespace, policyName, serviceName) + policyKey := types.NamespacedName{ + Namespace: ingress.Namespace, + Name: policyName, + } + + ir.BackendTLSPolicies[policyKey] = policy + } + + // Add warning about manual certificate configuration + if len(sslServiceSet) > 0 { + message := "nginx.org/ssl-services: " + BackendTLSPolicyKind + " created but requires manual configuration. You must set the 'validation.hostname' field to match your backend service's TLS certificate hostname, and configure appropriate CA certificates or certificateRefs for TLS verification." + notify(notifications.WarningNotification, message, &ingress) + } + + return errs +} + +// BackendTLSPolicyName returns the generated name for a BackendTLSPolicy using NGINX naming convention +func BackendTLSPolicyName(ingressName, serviceName string) string { + return fmt.Sprintf("%s-%s-backend-tls", ingressName, serviceName) +} diff --git a/pkg/i2gw/providers/nginx/annotations/ssl_services_test.go b/pkg/i2gw/providers/nginx/annotations/ssl_services_test.go new file mode 100644 index 000000000..01a03b6aa --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/ssl_services_test.go @@ -0,0 +1,246 @@ +/* +Copyright 2025 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 annotations + +import ( + "testing" + + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gatewayv1alpha3 "sigs.k8s.io/gateway-api/apis/v1alpha3" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" +) + +func TestSSLServicesAnnotation(t *testing.T) { + tests := []struct { + name string + annotation string + expectedPolicies int + expectedServices []string + }{ + { + name: "single service", + annotation: "secure-api", + expectedPolicies: 1, + expectedServices: []string{"secure-api"}, + }, + { + name: "multiple services", + annotation: "secure-api,auth-service", + expectedPolicies: 2, + expectedServices: []string{"secure-api", "auth-service"}, + }, + { + name: "spaces in annotation", + annotation: " secure-api , auth-service , payment-api ", + expectedPolicies: 3, + expectedServices: []string{"secure-api", "auth-service", "payment-api"}, + }, + { + name: "empty annotation", + annotation: "", + expectedPolicies: 0, + expectedServices: []string{}, + }, + { + name: "empty values in annotation", + annotation: "secure-api,,auth-service,", + expectedPolicies: 2, + expectedServices: []string{"secure-api", "auth-service"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + nginxSSLServicesAnnotation: tt.annotation, + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-service", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + ir := intermediate.IR{ + BackendTLSPolicies: make(map[types.NamespacedName]gatewayv1alpha3.BackendTLSPolicy), + } + + errs := processSSLServicesAnnotation(ingress, tt.annotation, &ir) + if len(errs) > 0 { + t.Errorf("Unexpected errors: %v", errs) + return + } + + if len(ir.BackendTLSPolicies) != tt.expectedPolicies { + t.Errorf("Expected %d BackendTLSPolicies, got %d", tt.expectedPolicies, len(ir.BackendTLSPolicies)) + } + + serviceNames := make(map[string]struct{}) + for _, policy := range ir.BackendTLSPolicies { + if len(policy.Spec.TargetRefs) > 0 { + serviceName := string(policy.Spec.TargetRefs[0].Name) + serviceNames[serviceName] = struct{}{} + + if policy.Spec.TargetRefs[0].Kind != "Service" { + t.Errorf("Expected TargetRef Kind 'Service', got '%s'", policy.Spec.TargetRefs[0].Kind) + } + if policy.Spec.TargetRefs[0].Group != "" { + t.Errorf("Expected TargetRef Group '%s', got '%s'", "", policy.Spec.TargetRefs[0].Group) + } + + } + } + + // Verify all expected services are present + for _, expectedService := range tt.expectedServices { + if _, exists := serviceNames[expectedService]; !exists { + t.Errorf("Expected BackendTLSPolicy for service '%s' not found", expectedService) + } + } + }) + } +} + +func TestSSLServicesFeature(t *testing.T) { + tests := []struct { + name string + ingresses []networkingv1.Ingress + expectedPolicies int + }{ + { + name: "multiple ingresses with SSL services", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-1", + Namespace: "default", + Annotations: map[string]string{ + nginxSSLServicesAnnotation: "api-service", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress-2", + Namespace: "default", + Annotations: map[string]string{ + nginxSSLServicesAnnotation: "auth-service,payment-service", + }, + }, + }, + }, + expectedPolicies: 3, + }, + { + name: "ingress without SSL services annotation", + ingresses: []networkingv1.Ingress{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "default", + Annotations: map[string]string{ + "other-annotation": "value", + }, + }, + }, + }, + expectedPolicies: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ir := intermediate.IR{ + BackendTLSPolicies: make(map[types.NamespacedName]gatewayv1alpha3.BackendTLSPolicy), + } + + errs := SSLServicesFeature(tt.ingresses, nil, &ir) + if len(errs) > 0 { + t.Errorf("Unexpected errors: %v", errs) + return + } + + if len(ir.BackendTLSPolicies) != tt.expectedPolicies { + t.Errorf("Expected %d BackendTLSPolicies, got %d", tt.expectedPolicies, len(ir.BackendTLSPolicies)) + } + }) + } +} + +func TestBackendTLSPolicyName(t *testing.T) { + testCases := []struct { + name string + ingressName string + serviceName string + expected string + }{ + { + name: "basic name generation", + ingressName: "test-ingress", + serviceName: "ssl-service", + expected: "test-ingress-ssl-service-backend-tls", + }, + { + name: "long names", + ingressName: "very-long-ingress-name", + serviceName: "very-long-service-name", + expected: "very-long-ingress-name-very-long-service-name-backend-tls", + }, + { + name: "names with hyphens", + ingressName: "my-api-ingress", + serviceName: "backend-svc", + expected: "my-api-ingress-backend-svc-backend-tls", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + result := BackendTLSPolicyName(tc.ingressName, tc.serviceName) + require.Equal(t, tc.expected, result) + }) + } +} diff --git a/pkg/i2gw/providers/nginx/annotations/utils.go b/pkg/i2gw/providers/nginx/annotations/utils.go new file mode 100644 index 000000000..87bda2115 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/utils.go @@ -0,0 +1,37 @@ +/* +Copyright 2025 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 annotations + +import "strings" + +// splitAndTrimCommaList splits a comma-separated string and trims whitespace from each part +func splitAndTrimCommaList(input string) []string { + if input == "" { + return nil + } + + var result []string + parts := strings.Split(input, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + + return result +} diff --git a/pkg/i2gw/providers/nginx/annotations/websocket_services.go b/pkg/i2gw/providers/nginx/annotations/websocket_services.go new file mode 100644 index 000000000..8fae45570 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/websocket_services.go @@ -0,0 +1,37 @@ +/* +Copyright 2025 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 annotations + +import ( + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/notifications" +) + +func WebSocketServicesFeature(ingresses []networkingv1.Ingress, _ map[types.NamespacedName]map[string]int32, _ *intermediate.IR) field.ErrorList { + for _, ingress := range ingresses { + if webSocketServices, exists := ingress.Annotations[nginxWebSocketServicesAnnotation]; exists && webSocketServices != "" { + message := "nginx.org/websocket-services: Please make sure the services are configured to support WebSocket connections. This annotation does not create any Gateway API resources." + notify(notifications.InfoNotification, message, &ingress) + } + } + + return nil +} diff --git a/pkg/i2gw/providers/nginx/annotations/websocket_services_test.go b/pkg/i2gw/providers/nginx/annotations/websocket_services_test.go new file mode 100644 index 000000000..0a2c5a4c9 --- /dev/null +++ b/pkg/i2gw/providers/nginx/annotations/websocket_services_test.go @@ -0,0 +1,61 @@ +/* +Copyright 2025 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 annotations + +import ( + "testing" + + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" +) + +func TestWebSocketServicesFeature(t *testing.T) { + t.Run("with annotation", func(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "websocket-ingress", + Namespace: "default", + Annotations: map[string]string{ + nginxWebSocketServicesAnnotation: "websocket-service", + }, + }, + } + + ir := intermediate.IR{} + errs := WebSocketServicesFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Errorf("Unexpected errors: %v", errs) + } + }) + + t.Run("without annotation", func(t *testing.T) { + ingress := networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "regular-ingress", + Namespace: "default", + }, + } + + ir := intermediate.IR{} + errs := WebSocketServicesFeature([]networkingv1.Ingress{ingress}, nil, &ir) + if len(errs) > 0 { + t.Errorf("Unexpected errors: %v", errs) + } + }) +} diff --git a/pkg/i2gw/providers/nginx/converter.go b/pkg/i2gw/providers/nginx/converter.go new file mode 100644 index 000000000..33b91f427 --- /dev/null +++ b/pkg/i2gw/providers/nginx/converter.go @@ -0,0 +1,70 @@ +/* +Copyright 2025 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 nginx + +import ( + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/nginx/annotations" +) + +type resourcesToIRConverter struct { + featureParsers []i2gw.FeatureParser + implementationSpecificOptions i2gw.ProviderImplementationSpecificOptions +} + +func newResourcesToIRConverter() *resourcesToIRConverter { + return &resourcesToIRConverter{ + featureParsers: []i2gw.FeatureParser{ + annotations.ListenPortsFeature, + annotations.RewriteTargetFeature, + annotations.HeaderManipulationFeature, + annotations.PathRegexFeature, + annotations.SSLRedirectFeature, + annotations.HSTSFeature, + annotations.WebSocketServicesFeature, + annotations.SSLServicesFeature, + annotations.GRPCServicesFeature, + }, + implementationSpecificOptions: i2gw.ProviderImplementationSpecificOptions{}, + } +} + +func (c *resourcesToIRConverter) convert(storage *storage) (intermediate.IR, field.ErrorList) { + ingressList := []networkingv1.Ingress{} + for _, ingress := range storage.Ingresses { + if ingress != nil { + ingressList = append(ingressList, *ingress) + } + } + + ir, errorList := common.ToIR(ingressList, storage.ServicePorts, c.implementationSpecificOptions) + if len(errorList) > 0 { + return intermediate.IR{}, errorList + } + + for _, parseFeatureFunc := range c.featureParsers { + errs := parseFeatureFunc(ingressList, storage.ServicePorts, &ir) + errorList = append(errorList, errs...) + } + + return ir, errorList +} diff --git a/pkg/i2gw/providers/nginx/converter_test.go b/pkg/i2gw/providers/nginx/converter_test.go new file mode 100644 index 000000000..e8f2915a2 --- /dev/null +++ b/pkg/i2gw/providers/nginx/converter_test.go @@ -0,0 +1,40 @@ +/* +Copyright 2025 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 nginx + +import ( + "testing" +) + +func TestNewResourcesToIRConverter(t *testing.T) { + tests := []struct { + name string + want *resourcesToIRConverter + }{ + { + name: "basic", + want: &resourcesToIRConverter{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := newResourcesToIRConverter(); got == nil { + t.Errorf("newResourcesToIRConverter() = %v, want non-nil", got) + } + }) + } +} diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-grpc-services.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-grpc-services.yaml new file mode 100644 index 000000000..4455d3f83 --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-grpc-services.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: grpc-services-test + namespace: default + annotations: + nginx.org/grpc-services: "grpc-svc" +spec: + ingressClassName: nginx + rules: + - host: grpc.example.com + http: + paths: + - path: /helloworld.Greeter/SayHello + pathType: Prefix + backend: + service: + name: grpc-svc + port: + number: 8080 \ No newline at end of file diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-hsts.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-hsts.yaml new file mode 100644 index 000000000..a0382a02a --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-hsts.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: hsts-test + namespace: default + annotations: + nginx.org/hsts: "true" + nginx.org/hsts-max-age: "31536000" + nginx.org/hsts-include-subdomains: "true" +spec: + ingressClassName: nginx + tls: + - hosts: + - hsts.example.com + secretName: hsts-tls + rules: + - host: hsts.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: secure-app + port: + number: 443 \ No newline at end of file diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-listen-ports.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-listen-ports.yaml new file mode 100644 index 000000000..d2d902f48 --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-listen-ports.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: listen-ports-test + namespace: default + annotations: + nginx.org/listen-ports: "8080,9090" + nginx.org/listen-ports-ssl: "8443" +spec: + tls: + - secretName: custom-ports-tls + ingressClassName: nginx + rules: + - host: custom-ports.example.com + http: + paths: + - path: /app + pathType: Prefix + backend: + service: + name: app-service + port: + number: 8080 \ No newline at end of file diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-path-regex.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-path-regex.yaml new file mode 100644 index 000000000..f13ecd161 --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-path-regex.yaml @@ -0,0 +1,27 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: test-path-regex + namespace: default + annotations: + nginx.org/path-regex: "true" +spec: + ingressClassName: nginx + rules: + - host: example.com + http: + paths: + - path: /api/v[0-9]+ + pathType: Prefix + backend: + service: + name: api-service + port: + number: 80 + - path: /static + pathType: Prefix + backend: + service: + name: static-service + port: + number: 8080 \ No newline at end of file diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-proxy-hide-headers.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-proxy-hide-headers.yaml new file mode 100644 index 000000000..3857ac62b --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-proxy-hide-headers.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: proxy-hide-headers-test + namespace: default + annotations: + nginx.org/proxy-hide-headers: "X-Secret-Token,X-Internal-Header" +spec: + ingressClassName: nginx + rules: + - host: hide-headers.example.com + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: api-service + port: + number: 8080 \ No newline at end of file diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-proxy-set-headers.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-proxy-set-headers.yaml new file mode 100644 index 000000000..08f99bf0f --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-proxy-set-headers.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: proxy-set-headers-test + namespace: default + annotations: + nginx.org/proxy-set-headers: "header-a:value-a,header-b:value-b" +spec: + ingressClassName: nginx + rules: + - host: set-headers.example.com + http: + paths: + - path: /echo + pathType: Prefix + backend: + service: + name: echo-service + port: + number: 8080 \ No newline at end of file diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-rewrite.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-rewrite.yaml new file mode 100644 index 000000000..332af001e --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-rewrite.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: test-rewrite + namespace: default + annotations: + nginx.org/rewrites: "serviceName=web-service rewrite=/api/v1" +spec: + ingressClassName: nginx + rules: + - host: example.com + http: + paths: + - path: /app + pathType: Prefix + backend: + service: + name: web-service + port: + number: 80 diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-ssl-redirect.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-ssl-redirect.yaml new file mode 100644 index 000000000..2beba1dde --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-ssl-redirect.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: test-ssl-redirect + namespace: default + annotations: + nginx.org/redirect-to-https: "true" +spec: + ingressClassName: nginx + rules: + - host: secure.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: secure-service + port: + number: 80 diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-ssl-services.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-ssl-services.yaml new file mode 100644 index 000000000..07126485b --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/input/nginx-ssl-services.yaml @@ -0,0 +1,27 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ssl-services-test + namespace: default + annotations: + nginx.org/ssl-services: "secure-app" +spec: + ingressClassName: nginx + rules: + - host: cafe.example.com + http: + paths: + - path: /ssl + pathType: Prefix + backend: + service: + name: secure-app + port: + number: 8443 + - path: /tea + pathType: Prefix + backend: + service: + name: tea + port: + number: 80 \ No newline at end of file diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-grpc-services.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-grpc-services.yaml new file mode 100644 index 000000000..df2731af7 --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-grpc-services.yaml @@ -0,0 +1,40 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: nginx + namespace: default +spec: + gatewayClassName: nginx + listeners: + - hostname: grpc.example.com + name: grpc-example-com-http + port: 80 + protocol: HTTP +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: GRPCRoute +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: grpc-services-test-grpc-example-com + namespace: default +spec: + hostnames: + - grpc.example.com + parentRefs: + - name: nginx + rules: + - backendRefs: + - name: grpc-svc + port: 8080 + matches: + - method: + method: SayHello + service: helloworld.Greeter +status: + parents: null diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-hsts.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-hsts.yaml new file mode 100644 index 000000000..8fae340f2 --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-hsts.yaml @@ -0,0 +1,55 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: nginx + namespace: default +spec: + gatewayClassName: nginx + listeners: + - hostname: hsts.example.com + name: hsts-example-com-http + port: 80 + protocol: HTTP + - hostname: hsts.example.com + name: hsts-example-com-https + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - group: null + kind: null + name: hsts-tls +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: hsts-test-hsts-example-com + namespace: default +spec: + hostnames: + - hsts.example.com + parentRefs: + - name: nginx + rules: + - backendRefs: + - name: secure-app + port: 443 + filters: + - responseHeaderModifier: + set: + - name: Strict-Transport-Security + value: max-age=31536000; includeSubDomains + type: ResponseHeaderModifier + matches: + - path: + type: PathPrefix + value: / +status: + parents: [] diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-listen-ports.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-listen-ports.yaml new file mode 100644 index 000000000..b55c4516b --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-listen-ports.yaml @@ -0,0 +1,55 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: nginx + namespace: default +spec: + gatewayClassName: nginx + listeners: + - hostname: custom-ports.example.com + name: custom-ports-example-com-https-8443 + port: 8443 + protocol: HTTPS + tls: + certificateRefs: + - group: null + kind: null + name: custom-ports-tls + namespace: default + mode: Terminate + - hostname: custom-ports.example.com + name: custom-ports-example-com-http-8080 + port: 8080 + protocol: HTTP + - hostname: custom-ports.example.com + name: custom-ports-example-com-http-9090 + port: 9090 + protocol: HTTP +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: listen-ports-test-custom-ports-example-com + namespace: default +spec: + hostnames: + - custom-ports.example.com + parentRefs: + - name: nginx + rules: + - backendRefs: + - name: app-service + port: 8080 + matches: + - path: + type: PathPrefix + value: /app +status: + parents: [] diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-path-regex.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-path-regex.yaml new file mode 100644 index 000000000..9e179ac7c --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-path-regex.yaml @@ -0,0 +1,54 @@ +Notifications from NGINX: ++--------------+-------------------------------------------------------------------------------------------------------------------------------------+----------------------------------+ +| MESSAGE TYPE | NOTIFICATION | CALLING OBJECT | ++--------------+-------------------------------------------------------------------------------------------------------------------------------------+----------------------------------+ +| WARNING | nginx.org/path-regex: PathMatchRegularExpression is not supported by NGINX Gateway Fabric - only Exact and PathPrefix are supported | Ingress: default/test-path-regex | ++--------------+-------------------------------------------------------------------------------------------------------------------------------------+----------------------------------+ + +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: nginx + namespace: default +spec: + gatewayClassName: nginx + listeners: + - hostname: example.com + name: example-com-http + port: 80 + protocol: HTTP +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: test-path-regex-example-com + namespace: default +spec: + hostnames: + - example.com + parentRefs: + - name: nginx + rules: + - backendRefs: + - name: api-service + port: 80 + matches: + - path: + type: RegularExpression + value: /api/v[0-9]+ + - backendRefs: + - name: static-service + port: 8080 + matches: + - path: + type: RegularExpression + value: /static +status: + parents: [] diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-proxy-hide-headers.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-proxy-hide-headers.yaml new file mode 100644 index 000000000..dbdb0d937 --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-proxy-hide-headers.yaml @@ -0,0 +1,46 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: nginx + namespace: default +spec: + gatewayClassName: nginx + listeners: + - hostname: hide-headers.example.com + name: hide-headers-example-com-http + port: 80 + protocol: HTTP +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: proxy-hide-headers-test-hide-headers-example-com + namespace: default +spec: + hostnames: + - hide-headers.example.com + parentRefs: + - name: nginx + rules: + - backendRefs: + - name: api-service + port: 8080 + filters: + - responseHeaderModifier: + remove: + - X-Secret-Token + - X-Internal-Header + type: ResponseHeaderModifier + matches: + - path: + type: PathPrefix + value: /api +status: + parents: [] diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-proxy-set-headers.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-proxy-set-headers.yaml new file mode 100644 index 000000000..08e761e41 --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-proxy-set-headers.yaml @@ -0,0 +1,48 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: nginx + namespace: default +spec: + gatewayClassName: nginx + listeners: + - hostname: set-headers.example.com + name: set-headers-example-com-http + port: 80 + protocol: HTTP +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: proxy-set-headers-test-set-headers-example-com + namespace: default +spec: + hostnames: + - set-headers.example.com + parentRefs: + - name: nginx + rules: + - backendRefs: + - name: echo-service + port: 8080 + filters: + - requestHeaderModifier: + set: + - name: header-a + value: value-a + - name: header-b + value: value-b + type: RequestHeaderModifier + matches: + - path: + type: PathPrefix + value: /echo +status: + parents: [] diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-rewrite.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-rewrite.yaml new file mode 100644 index 000000000..4960fec48 --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-rewrite.yaml @@ -0,0 +1,46 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: nginx + namespace: default +spec: + gatewayClassName: nginx + listeners: + - hostname: example.com + name: example-com-http + port: 80 + protocol: HTTP +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: test-rewrite-example-com + namespace: default +spec: + hostnames: + - example.com + parentRefs: + - name: nginx + rules: + - backendRefs: + - name: web-service + port: 80 + filters: + - type: URLRewrite + urlRewrite: + path: + replacePrefixMatch: /api/v1 + type: ReplacePrefixMatch + matches: + - path: + type: PathPrefix + value: /app +status: + parents: [] diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-ssl-redirect.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-ssl-redirect.yaml new file mode 100644 index 000000000..3668bce60 --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-ssl-redirect.yaml @@ -0,0 +1,56 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: nginx + namespace: default +spec: + gatewayClassName: nginx + listeners: + - hostname: secure.example.com + name: secure-example-com-http + port: 80 + protocol: HTTP + - hostname: secure.example.com + name: https-secure-example-com + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - group: null + kind: null + name: secure-example-com-tls + mode: Terminate +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: test-ssl-redirect-secure-example-com + namespace: default +spec: + hostnames: + - secure.example.com + parentRefs: + - name: nginx + sectionName: secure-example-com-http + rules: + - filters: + - requestRedirect: + scheme: https + statusCode: 301 + type: RequestRedirect + - backendRefs: + - name: secure-service + port: 80 + matches: + - path: + type: PathPrefix + value: / +status: + parents: [] diff --git a/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-ssl-services.yaml b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-ssl-services.yaml new file mode 100644 index 000000000..aed66af5b --- /dev/null +++ b/pkg/i2gw/providers/nginx/fixtures/annotations/output/nginx-ssl-services.yaml @@ -0,0 +1,73 @@ +Notifications from NGINX: ++--------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------------------------------------+ +| MESSAGE TYPE | NOTIFICATION | CALLING OBJECT | ++--------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------------------------------------+ +| WARNING | nginx.org/ssl-services: BackendTLSPolicy created but requires manual configuration. You must set the 'validation.hostname' field to match your backend service's TLS certificate hostname, and configure | Ingress: default/ssl-services-test | +| | appropriate CA certificates or certificateRefs for TLS verification. | | ++--------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------------------------------------+ + +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: nginx + namespace: default +spec: + gatewayClassName: nginx + listeners: + - hostname: cafe.example.com + name: cafe-example-com-http + port: 80 + protocol: HTTP +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: ssl-services-test-cafe-example-com + namespace: default +spec: + hostnames: + - cafe.example.com + parentRefs: + - name: nginx + rules: + - backendRefs: + - name: secure-app + port: 8443 + matches: + - path: + type: PathPrefix + value: /ssl + - backendRefs: + - name: tea + port: 80 + matches: + - path: + type: PathPrefix + value: /tea +status: + parents: [] +--- +apiVersion: gateway.networking.k8s.io/v1alpha3 +kind: BackendTLSPolicy +metadata: + annotations: + gateway.networking.k8s.io/generator: ingress2gateway-dev + creationTimestamp: null + name: ssl-services-test-secure-app-backend-tls + namespace: default +spec: + targetRefs: + - group: "" + kind: Service + name: secure-app + validation: + hostname: "" +status: + ancestors: null diff --git a/pkg/i2gw/providers/nginx/gateway_converter.go b/pkg/i2gw/providers/nginx/gateway_converter.go new file mode 100644 index 000000000..be61a4644 --- /dev/null +++ b/pkg/i2gw/providers/nginx/gateway_converter.go @@ -0,0 +1,43 @@ +/* +Copyright 2025 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 nginx + +import ( + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +// gatewayResourcesConverter converts intermediate representation to Gateway API resources with NGINX-specific extensions +type gatewayResourcesConverter struct{} + +// newGatewayResourcesConverter creates a new gateway resources converter +func newGatewayResourcesConverter() *gatewayResourcesConverter { + return &gatewayResourcesConverter{} +} + +// convert converts IR to Gateway API resources including NGINX Gateway Fabric custom policies +func (c *gatewayResourcesConverter) convert(ir intermediate.IR) (i2gw.GatewayResources, field.ErrorList) { + // Start with standard Gateway API resources + gatewayResources, errs := common.ToGatewayResources(ir) + if len(errs) != 0 { + return i2gw.GatewayResources{}, errs + } + return gatewayResources, errs +} diff --git a/pkg/i2gw/providers/nginx/gateway_converter_test.go b/pkg/i2gw/providers/nginx/gateway_converter_test.go new file mode 100644 index 000000000..d1883d070 --- /dev/null +++ b/pkg/i2gw/providers/nginx/gateway_converter_test.go @@ -0,0 +1,40 @@ +/* +Copyright 2025 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 nginx + +import ( + "testing" +) + +func TestNewGatewayResourcesConverter(t *testing.T) { + tests := []struct { + name string + want *gatewayResourcesConverter + }{ + { + name: "basic", + want: &gatewayResourcesConverter{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := newGatewayResourcesConverter(); got == nil { + t.Errorf("newGatewayResourcesConverter() = %v, want non-nil", got) + } + }) + } +} diff --git a/pkg/i2gw/providers/nginx/nginx.go b/pkg/i2gw/providers/nginx/nginx.go new file mode 100644 index 000000000..a7a1e3499 --- /dev/null +++ b/pkg/i2gw/providers/nginx/nginx.go @@ -0,0 +1,78 @@ +/* +Copyright 2025 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 nginx + +import ( + "context" + + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/intermediate" +) + +const Name = "nginx" + +func init() { + i2gw.ProviderConstructorByName[Name] = NewProvider +} + +type Provider struct { + *storage + *resourceReader + *resourcesToIRConverter + *gatewayResourcesConverter +} + +// NewProvider constructs and returns the nginx implementation of i2gw.Provider +func NewProvider(conf *i2gw.ProviderConf) i2gw.Provider { + return &Provider{ + resourceReader: newResourceReader(conf), + resourcesToIRConverter: newResourcesToIRConverter(), + gatewayResourcesConverter: newGatewayResourcesConverter(), + } +} + +// ReadResourcesFromCluster reads resources from the Kubernetes cluster +func (p *Provider) ReadResourcesFromCluster(ctx context.Context) error { + storage, err := p.readResourcesFromCluster(ctx) + if err != nil { + return err + } + p.storage = storage + return nil +} + +// ReadResourcesFromFile reads resources from a YAML file +func (p *Provider) ReadResourcesFromFile(_ context.Context, filename string) error { + storage, err := p.readResourcesFromFile(filename) + if err != nil { + return err + } + p.storage = storage + return nil +} + +// ToIR converts the provider resources to intermediate representation +func (p *Provider) ToIR() (intermediate.IR, field.ErrorList) { + return p.resourcesToIRConverter.convert(p.storage) +} + +// ToGatewayResources converts the IR to Gateway API resources +func (p *Provider) ToGatewayResources(ir intermediate.IR) (i2gw.GatewayResources, field.ErrorList) { + return p.gatewayResourcesConverter.convert(ir) +} diff --git a/pkg/i2gw/providers/nginx/nginx_test.go b/pkg/i2gw/providers/nginx/nginx_test.go new file mode 100644 index 000000000..996058864 --- /dev/null +++ b/pkg/i2gw/providers/nginx/nginx_test.go @@ -0,0 +1,42 @@ +/* +Copyright 2025 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 nginx + +import ( + "testing" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw" +) + +func TestNewProvider(t *testing.T) { + tests := []struct { + name string + conf *i2gw.ProviderConf + }{ + { + name: "basic", + conf: &i2gw.ProviderConf{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewProvider(tt.conf); got == nil { + t.Errorf("NewProvider() = %v, want non-nil", got) + } + }) + } +} diff --git a/pkg/i2gw/providers/nginx/resource_reader.go b/pkg/i2gw/providers/nginx/resource_reader.go new file mode 100644 index 000000000..31fcbcfe7 --- /dev/null +++ b/pkg/i2gw/providers/nginx/resource_reader.go @@ -0,0 +1,80 @@ +/* +Copyright 2025 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 nginx + +import ( + "context" + + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +// NginxIngressClasses contains NGINX IngressClass names +var NginxIngressClasses = sets.New( + "nginx", +) + +type resourceReader struct { + conf *i2gw.ProviderConf +} + +// newResourceReader returns a resourceReader instance +func newResourceReader(conf *i2gw.ProviderConf) *resourceReader { + return &resourceReader{ + conf: conf, + } +} + +// readResourcesFromCluster reads nginx resources from the Kubernetes cluster +func (r *resourceReader) readResourcesFromCluster(ctx context.Context) (*storage, error) { + storage := newResourceStorage() + + ingresses, err := common.ReadIngressesFromCluster(ctx, r.conf.Client, NginxIngressClasses) + if err != nil { + return nil, err + } + storage.Ingresses = ingresses + + services, err := common.ReadServicesFromCluster(ctx, r.conf.Client) + if err != nil { + return nil, err + } + storage.ServicePorts = common.GroupServicePortsByPortName(services) + + return storage, nil +} + +// readResourcesFromFile reads nginx resources from a YAML file +func (r *resourceReader) readResourcesFromFile(filename string) (*storage, error) { + storage := newResourceStorage() + + ingresses, err := common.ReadIngressesFromFile(filename, r.conf.Namespace, NginxIngressClasses) + if err != nil { + return nil, err + } + storage.Ingresses = ingresses + + services, err := common.ReadServicesFromFile(filename, r.conf.Namespace) + if err != nil { + return nil, err + } + storage.ServicePorts = common.GroupServicePortsByPortName(services) + + return storage, nil +} diff --git a/pkg/i2gw/providers/nginx/storage.go b/pkg/i2gw/providers/nginx/storage.go new file mode 100644 index 000000000..655572f92 --- /dev/null +++ b/pkg/i2gw/providers/nginx/storage.go @@ -0,0 +1,35 @@ +/* +Copyright 2025 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 nginx + +import ( + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/types" +) + +type storage struct { + Ingresses map[types.NamespacedName]*networkingv1.Ingress + ServicePorts map[types.NamespacedName]map[string]int32 +} + +// newResourceStorage creates a new storage instance +func newResourceStorage() *storage { + return &storage{ + Ingresses: map[types.NamespacedName]*networkingv1.Ingress{}, + ServicePorts: map[types.NamespacedName]map[string]int32{}, + } +}