diff --git a/api/v1alpha1/httproutefilter_types.go b/api/v1alpha1/httproutefilter_types.go index b8fe13f5296d..80eba4caa56a 100644 --- a/api/v1alpha1/httproutefilter_types.go +++ b/api/v1alpha1/httproutefilter_types.go @@ -56,14 +56,18 @@ const ( type ReplaceRegexMatch struct { // Pattern matches a regular expression against the value of the HTTP Path.The regex string must // adhere to the syntax documented in https://github.com/google/re2/wiki/Syntax. + // +kubebuilder:validation:MinLength=1 Pattern string `json:"pattern"` // Substitution is an expression that replaces the matched portion.The expression may include numbered // capture groups that adhere to syntax documented in https://github.com/google/re2/wiki/Syntax. + // +kubebuilder:validation:MinLength=1 Substitution string `json:"substitution"` } +// +kubebuilder:validation:XValidation:rule="self.type == 'ReplaceRegexMatch' ? has(self.replaceRegexMatch) : !has(self.replaceRegexMatch)",message="If HTTPPathModifier type is ReplaceRegexMatch, replaceRegexMatch field needs to be set." type HTTPPathModifier struct { - // +kubebuilder:validation:Enum=RegexHTTPPathModifier + // +kubebuilder:validation:Enum=ReplaceRegexMatch + // +kubebuilder:validation:Required Type HTTPPathModifierType `json:"type"` // ReplaceRegexMatch defines a path regex rewrite. The path portions matched by the regex pattern are replaced by the defined substitution. // https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-regex-rewrite @@ -84,6 +88,7 @@ type HTTPPathModifier struct { // pattern: (?i)/xxx/ // substitution: /yyy/ // Would transform path /aaa/XxX/bbb into /aaa/yyy/bbb (case-insensitive). + // +optional ReplaceRegexMatch *ReplaceRegexMatch `json:"replaceRegexMatch,omitempty"` } diff --git a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml index 2bba4d20ff15..54a49c2b0ebf 100644 --- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml +++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml @@ -82,11 +82,13 @@ spec: description: |- Pattern matches a regular expression against the value of the HTTP Path.The regex string must adhere to the syntax documented in https://github.com/google/re2/wiki/Syntax. + minLength: 1 type: string substitution: description: |- Substitution is an expression that replaces the matched portion.The expression may include numbered capture groups that adhere to syntax documented in https://github.com/google/re2/wiki/Syntax. + minLength: 1 type: string required: - pattern @@ -96,11 +98,16 @@ spec: description: HTTPPathModifierType defines the type of path redirect or rewrite. enum: - - RegexHTTPPathModifier + - ReplaceRegexMatch type: string required: - type type: object + x-kubernetes-validations: + - message: If HTTPPathModifier type is ReplaceRegexMatch, replaceRegexMatch + field needs to be set. + rule: 'self.type == ''ReplaceRegexMatch'' ? has(self.replaceRegexMatch) + : !has(self.replaceRegexMatch)' type: object type: object required: diff --git a/charts/gateway-helm/templates/_rbac.tpl b/charts/gateway-helm/templates/_rbac.tpl index fb9304e7d89f..27e90061b0ce 100644 --- a/charts/gateway-helm/templates/_rbac.tpl +++ b/charts/gateway-helm/templates/_rbac.tpl @@ -71,6 +71,7 @@ resources: - securitypolicies - envoyextensionpolicies - backends +- httproutefilters verbs: - get - list diff --git a/internal/gatewayapi/filters.go b/internal/gatewayapi/filters.go index de6be3528158..a70b0c1b8343 100644 --- a/internal/gatewayapi/filters.go +++ b/internal/gatewayapi/filters.go @@ -7,11 +7,13 @@ package gatewayapi import ( "fmt" + "regexp" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/envoyproxy/gateway/internal/gatewayapi/resource" "github.com/envoyproxy/gateway/internal/gatewayapi/status" "github.com/envoyproxy/gateway/internal/ir" @@ -150,16 +152,20 @@ func (t *Translator) processURLRewriteFilter( filterContext *HTTPFiltersContext, ) { if filterContext.URLRewrite != nil { - routeStatus := GetRouteStatus(filterContext.Route) - status.SetRouteStatusCondition(routeStatus, - filterContext.ParentRef.routeParentStatusIdx, - filterContext.Route.GetGeneration(), - gwapiv1.RouteConditionAccepted, - metav1.ConditionFalse, - gwapiv1.RouteReasonUnsupportedValue, - "Cannot configure multiple urlRewrite filters for a single HTTPRouteRule", - ) - return + if filterContext.URLRewrite.Hostname != nil || + filterContext.URLRewrite.Path.FullReplace != nil || + filterContext.URLRewrite.Path.PrefixMatchReplace != nil { + routeStatus := GetRouteStatus(filterContext.Route) + status.SetRouteStatusCondition(routeStatus, + filterContext.ParentRef.routeParentStatusIdx, + filterContext.Route.GetGeneration(), + gwapiv1.RouteConditionAccepted, + metav1.ConditionFalse, + gwapiv1.RouteReasonUnsupportedValue, + "Cannot configure multiple urlRewrite filters for a single HTTPRouteRule", + ) + return + } } if rewrite == nil { @@ -215,8 +221,10 @@ func (t *Translator) processURLRewriteFilter( return } if rewrite.Path.ReplaceFullPath != nil { - newURLRewrite.Path = &ir.HTTPPathModifier{ - FullReplace: rewrite.Path.ReplaceFullPath, + newURLRewrite.Path = &ir.ExtendedHTTPPathModifier{ + HTTPPathModifier: ir.HTTPPathModifier{ + FullReplace: rewrite.Path.ReplaceFullPath, + }, } } case gwapiv1.PrefixMatchHTTPPathModifier: @@ -247,8 +255,10 @@ func (t *Translator) processURLRewriteFilter( return } if rewrite.Path.ReplacePrefixMatch != nil { - newURLRewrite.Path = &ir.HTTPPathModifier{ - PrefixMatchReplace: rewrite.Path.ReplacePrefixMatch, + newURLRewrite.Path = &ir.ExtendedHTTPPathModifier{ + HTTPPathModifier: ir.HTTPPathModifier{ + PrefixMatchReplace: rewrite.Path.ReplacePrefixMatch, + }, } } default: @@ -738,6 +748,85 @@ func (t *Translator) processExtensionRefHTTPFilter(extFilter *gwapiv1.LocalObjec } filterNs := filterContext.Route.GetNamespace() + + if string(extFilter.Kind) == egv1a1.KindHTTPRouteFilter { + for _, hrf := range resources.HTTPRouteFilters { + if hrf.Namespace == filterNs && hrf.Name == string(extFilter.Name) && + hrf.Spec.URLRewrite.Path.Type == egv1a1.RegexHTTPPathModifier { + + if hrf.Spec.URLRewrite.Path.ReplaceRegexMatch == nil || + hrf.Spec.URLRewrite.Path.ReplaceRegexMatch.Pattern == "" || + hrf.Spec.URLRewrite.Path.ReplaceRegexMatch.Substitution == "" { + errMsg := "ReplaceRegexMatch Pattern and Substitution must be set when rewrite path type is \"ReplaceRegexMatch\"" + routeStatus := GetRouteStatus(filterContext.Route) + status.SetRouteStatusCondition(routeStatus, + filterContext.ParentRef.routeParentStatusIdx, + filterContext.Route.GetGeneration(), + gwapiv1.RouteConditionAccepted, + metav1.ConditionFalse, + gwapiv1.RouteReasonUnsupportedValue, + errMsg, + ) + return + } else if _, err := regexp.Compile(hrf.Spec.URLRewrite.Path.ReplaceRegexMatch.Pattern); err != nil { + // Avoid envoy NACKs due to invalid regex. + // Golang's regexp is almost identical to RE2: https://pkg.go.dev/regexp/syntax + errMsg := "ReplaceRegexMatch must be a valid RE2 regular expression" + routeStatus := GetRouteStatus(filterContext.Route) + status.SetRouteStatusCondition(routeStatus, + filterContext.ParentRef.routeParentStatusIdx, + filterContext.Route.GetGeneration(), + gwapiv1.RouteConditionAccepted, + metav1.ConditionFalse, + gwapiv1.RouteReasonUnsupportedValue, + errMsg, + ) + return + } + + rmr := &ir.RegexMatchReplace{ + Pattern: hrf.Spec.URLRewrite.Path.ReplaceRegexMatch.Pattern, + Substitution: hrf.Spec.URLRewrite.Path.ReplaceRegexMatch.Substitution, + } + + if filterContext.HTTPFilterIR.URLRewrite != nil { + // If path IR is already set - check for a conflict + if filterContext.HTTPFilterIR.URLRewrite.Path != nil { + path := filterContext.HTTPFilterIR.URLRewrite.Path + if path.RegexMatchReplace != nil || path.PrefixMatchReplace != nil || path.FullReplace != nil { + routeStatus := GetRouteStatus(filterContext.Route) + status.SetRouteStatusCondition(routeStatus, + filterContext.ParentRef.routeParentStatusIdx, + filterContext.Route.GetGeneration(), + gwapiv1.RouteConditionAccepted, + metav1.ConditionFalse, + gwapiv1.RouteReasonUnsupportedValue, + "Cannot configure multiple urlRewrite filters for a single HTTPRouteRule", + ) + return + } + } else { // no path + filterContext.HTTPFilterIR.URLRewrite.Path = &ir.ExtendedHTTPPathModifier{ + RegexMatchReplace: rmr, + } + return + } + } else { // no url rewrite + filterContext.HTTPFilterIR.URLRewrite = &ir.URLRewrite{ + Path: &ir.ExtendedHTTPPathModifier{ + RegexMatchReplace: rmr, + }, + } + return + } + } + } + errMsg := fmt.Sprintf("Unable to translate HTTPRouteFilter: %s/%s", filterNs, + extFilter.Name) + t.processUnresolvedHTTPFilter(errMsg, filterContext) + return + } + // This list of resources will be empty unless an extension is loaded (and introduces resources) for _, res := range resources.ExtensionRefFilters { if res.GetKind() == string(extFilter.Kind) && res.GetName() == string(extFilter.Name) && res.GetNamespace() == filterNs { diff --git a/internal/gatewayapi/helpers.go b/internal/gatewayapi/helpers.go index 885077f5cc36..9c5626d75249 100644 --- a/internal/gatewayapi/helpers.go +++ b/internal/gatewayapi/helpers.go @@ -179,6 +179,9 @@ func ValidateHTTPRouteFilter(filter *gwapiv1.HTTPRouteFilter, extGKs ...schema.G switch { case filter.ExtensionRef == nil: return errors.New("extensionRef field must be specified for an extended filter") + case string(filter.ExtensionRef.Group) == egv1a1.GroupVersion.Group && + string(filter.ExtensionRef.Kind) == egv1a1.KindHTTPRouteFilter: + return nil default: for _, gk := range extGKs { if filter.ExtensionRef.Group == gwapiv1.Group(gk.Group) && diff --git a/internal/gatewayapi/resource/load.go b/internal/gatewayapi/resource/load.go index cacc3ebe61a0..317ad93418cf 100644 --- a/internal/gatewayapi/resource/load.go +++ b/internal/gatewayapi/resource/load.go @@ -265,6 +265,20 @@ func kubernetesYAMLToResources(str string, addMissingResources bool) (*Resources Spec: typedSpec.(egv1a1.SecurityPolicySpec), } resources.SecurityPolicies = append(resources.SecurityPolicies, securityPolicy) + case KindHTTPRouteFilter: + typedSpec := spec.Interface() + httpRouteFilter := &egv1a1.HTTPRouteFilter{ + TypeMeta: metav1.TypeMeta{ + Kind: KindHTTPRouteFilter, + APIVersion: egv1a1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Spec: typedSpec.(egv1a1.HTTPRouteFilterSpec), + } + resources.HTTPRouteFilters = append(resources.HTTPRouteFilters, httpRouteFilter) } } diff --git a/internal/gatewayapi/resource/resource.go b/internal/gatewayapi/resource/resource.go index a5d9e6fffb57..97468511fa83 100644 --- a/internal/gatewayapi/resource/resource.go +++ b/internal/gatewayapi/resource/resource.go @@ -63,6 +63,7 @@ type Resources struct { EnvoyExtensionPolicies []*egv1a1.EnvoyExtensionPolicy `json:"envoyExtensionPolicies,omitempty" yaml:"envoyExtensionPolicies,omitempty"` ExtensionServerPolicies []unstructured.Unstructured `json:"extensionServerPolicies,omitempty" yaml:"extensionServerPolicies,omitempty"` Backends []*egv1a1.Backend `json:"backends,omitempty" yaml:"backends,omitempty"` + HTTPRouteFilters []*egv1a1.HTTPRouteFilter `json:"httpFilters,omitempty" yaml:"httpFilters,omitempty"` } func NewResources() *Resources { @@ -86,6 +87,7 @@ func NewResources() *Resources { EnvoyExtensionPolicies: []*egv1a1.EnvoyExtensionPolicy{}, ExtensionServerPolicies: []unstructured.Unstructured{}, Backends: []*egv1a1.Backend{}, + HTTPRouteFilters: []*egv1a1.HTTPRouteFilter{}, } } diff --git a/internal/gatewayapi/resource/supported_kind.go b/internal/gatewayapi/resource/supported_kind.go index 5c2c21954a96..e9d76e66c3d8 100644 --- a/internal/gatewayapi/resource/supported_kind.go +++ b/internal/gatewayapi/resource/supported_kind.go @@ -26,4 +26,5 @@ const ( KindService = "Service" KindServiceImport = "ServiceImport" KindSecret = "Secret" + KindHTTPRouteFilter = "HTTPRouteFilter" ) diff --git a/internal/gatewayapi/resource/zz_generated.deepcopy.go b/internal/gatewayapi/resource/zz_generated.deepcopy.go index 61cf9dfb46e0..06925b1467d6 100644 --- a/internal/gatewayapi/resource/zz_generated.deepcopy.go +++ b/internal/gatewayapi/resource/zz_generated.deepcopy.go @@ -279,6 +279,17 @@ func (in *Resources) DeepCopyInto(out *Resources) { } } } + if in.HTTPRouteFilters != nil { + in, out := &in.HTTPRouteFilters, &out.HTTPRouteFilters + *out = make([]*v1alpha1.HTTPRouteFilter, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(v1alpha1.HTTPRouteFilter) + (*in).DeepCopyInto(*out) + } + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Resources. diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-full-path-replace-http.out.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-full-path-replace-http.out.yaml index 3d8c69a6178c..01a20d2b5d6d 100644 --- a/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-full-path-replace-http.out.yaml +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-full-path-replace-http.out.yaml @@ -146,3 +146,4 @@ xdsIR: path: fullReplace: /rewrite prefixMatchReplace: null + regexMatchReplace: null diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-hostname-prefix-replace.out.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-hostname-prefix-replace.out.yaml index 1577ab27e64b..b796e6993a7f 100644 --- a/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-hostname-prefix-replace.out.yaml +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-hostname-prefix-replace.out.yaml @@ -148,3 +148,4 @@ xdsIR: path: fullReplace: null prefixMatchReplace: /rewrite + regexMatchReplace: null diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-prefix-replace-http.out.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-prefix-replace-http.out.yaml index dd20383d2ea2..4d85479537d1 100644 --- a/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-prefix-replace-http.out.yaml +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-prefix-replace-http.out.yaml @@ -146,3 +146,4 @@ xdsIR: path: fullReplace: null prefixMatchReplace: /rewrite + regexMatchReplace: null diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-http.in.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-http.in.yaml new file mode 100644 index 000000000000..374cebe6f177 --- /dev/null +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-http.in.yaml @@ -0,0 +1,111 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "*.envoyproxy.io" + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/valid" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-2 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/host-and-regex-path" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: URLRewrite + urlRewrite: + hostname: "rewrite.com" + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-3 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/regex-path-and-host" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid + - type: URLRewrite + urlRewrite: + hostname: "rewrite.com" +httpFilters: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: valid + namespace: default + spec: + urlRewrite: + path: + type: ReplaceRegexMatch + replaceRegexMatch: + pattern: '.*' + substitution: foo diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-http.out.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-http.out.yaml new file mode 100644 index 000000000000..5ba0e22ad87b --- /dev/null +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-http.out.yaml @@ -0,0 +1,295 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + hostname: '*.envoyproxy.io' + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 3 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-1 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid + type: ExtensionRef + matches: + - path: + value: /valid + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-2 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - type: URLRewrite + urlRewrite: + hostname: rewrite.com + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid + type: ExtensionRef + matches: + - path: + value: /host-and-regex-path + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-3 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid + type: ExtensionRef + - type: URLRewrite + urlRewrite: + hostname: rewrite.com + matches: + - path: + value: /regex-path-and-host + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - address: null + name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + name: envoy-gateway/gateway-1 +xdsIR: + envoy-gateway/gateway-1: + accessLog: + text: + - path: /dev/stdout + http: + - address: 0.0.0.0 + hostnames: + - '*.envoyproxy.io' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + name: httproute/default/httproute-2/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-2 + namespace: default + name: httproute/default/httproute-2/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /host-and-regex-path + urlRewrite: + hostname: rewrite.com + path: + fullReplace: null + prefixMatchReplace: null + regexMatchReplace: + pattern: .* + substitution: foo + - destination: + name: httproute/default/httproute-3/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-3 + namespace: default + name: httproute/default/httproute-3/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /regex-path-and-host + urlRewrite: + hostname: rewrite.com + - destination: + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /valid + urlRewrite: + path: + fullReplace: null + prefixMatchReplace: null + regexMatchReplace: + pattern: .* + substitution: foo diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-invalid.in.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-invalid.in.yaml new file mode 100644 index 000000000000..fc2a037755a8 --- /dev/null +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-invalid.in.yaml @@ -0,0 +1,266 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "*.envoyproxy.io" + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-invalid-pattern + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/invalid-pattern" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: invalid-pattern +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-missing-pattern + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/missing-pattern" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: missing-pattern +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-missing-substitution + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/missing-substitution" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: missing-substitution +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-multiple-path-rewrites-1 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/ext-first" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /rewrite +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-multiple-path-rewrites-2 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/inline-first" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /rewrite + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-multiple-regex-path-rewrites + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/two-regex" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid-2 +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-not-found + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/notfound" + backendRefs: + - name: service-1 + port: 8080 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: notfound +httpFilters: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: valid + namespace: default + spec: + urlRewrite: + path: + type: ReplaceRegexMatch + replaceRegexMatch: + pattern: '.*' + substitution: foo +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: valid-2 + namespace: default + spec: + urlRewrite: + path: + type: ReplaceRegexMatch + replaceRegexMatch: + pattern: '.*' + substitution: foo +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: invalid-pattern + namespace: default + spec: + urlRewrite: + path: + type: ReplaceRegexMatch + replaceRegexMatch: + pattern: '"([a-z]+)"*+?' + substitution: foo +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: missing-pattern + namespace: default + spec: + urlRewrite: + path: + type: ReplaceRegexMatch + replaceRegexMatch: + substitution: foo +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: missing-substitution + namespace: default + spec: + urlRewrite: + path: + type: ReplaceRegexMatch + replaceRegexMatch: + pattern: '.*' diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-invalid.out.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-invalid.out.yaml new file mode 100644 index 000000000000..d5ffd3a93e7e --- /dev/null +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-filter-regex-match-replace-invalid.out.yaml @@ -0,0 +1,430 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + hostname: '*.envoyproxy.io' + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 7 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-invalid-pattern + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: invalid-pattern + type: ExtensionRef + matches: + - path: + value: /invalid-pattern + status: + parents: + - conditions: + - lastTransitionTime: null + message: ReplaceRegexMatch must be a valid RE2 regular expression + reason: UnsupportedValue + status: "False" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-missing-pattern + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: missing-pattern + type: ExtensionRef + matches: + - path: + value: /missing-pattern + status: + parents: + - conditions: + - lastTransitionTime: null + message: ReplaceRegexMatch Pattern and Substitution must be set when rewrite + path type is "ReplaceRegexMatch" + reason: UnsupportedValue + status: "False" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-missing-substitution + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: missing-substitution + type: ExtensionRef + matches: + - path: + value: /missing-substitution + status: + parents: + - conditions: + - lastTransitionTime: null + message: ReplaceRegexMatch Pattern and Substitution must be set when rewrite + path type is "ReplaceRegexMatch" + reason: UnsupportedValue + status: "False" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-multiple-path-rewrites-1 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid + type: ExtensionRef + - type: URLRewrite + urlRewrite: + path: + replacePrefixMatch: /rewrite + type: ReplacePrefixMatch + matches: + - path: + value: /ext-first + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-multiple-path-rewrites-2 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - type: URLRewrite + urlRewrite: + path: + replacePrefixMatch: /rewrite + type: ReplacePrefixMatch + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid + type: ExtensionRef + matches: + - path: + value: /inline-first + status: + parents: + - conditions: + - lastTransitionTime: null + message: Cannot configure multiple urlRewrite filters for a single HTTPRouteRule + reason: UnsupportedValue + status: "False" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-multiple-regex-path-rewrites + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid + type: ExtensionRef + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: valid-2 + type: ExtensionRef + matches: + - path: + value: /two-regex + status: + parents: + - conditions: + - lastTransitionTime: null + message: Cannot configure multiple urlRewrite filters for a single HTTPRouteRule + reason: UnsupportedValue + status: "False" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-not-found + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: notfound + type: ExtensionRef + matches: + - path: + value: /notfound + status: + parents: + - conditions: + - lastTransitionTime: null + message: 'Unable to translate HTTPRouteFilter: default/notfound' + reason: UnsupportedValue + status: "False" + type: Accepted + - lastTransitionTime: null + message: 'Unable to translate HTTPRouteFilter: default/notfound' + reason: BackendNotFound + status: "False" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - address: null + name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + name: envoy-gateway/gateway-1 +xdsIR: + envoy-gateway/gateway-1: + accessLog: + text: + - path: /dev/stdout + http: + - address: 0.0.0.0 + hostnames: + - '*.envoyproxy.io' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + name: httproute/default/httproute-multiple-path-rewrites-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-multiple-path-rewrites-1 + namespace: default + name: httproute/default/httproute-multiple-path-rewrites-1/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /ext-first + urlRewrite: + path: + fullReplace: null + prefixMatchReplace: /rewrite + regexMatchReplace: null diff --git a/internal/ir/xds.go b/internal/ir/xds.go index a40cd8547665..80123ed1844d 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -51,8 +51,9 @@ var ( ErrDirectResponseStatusInvalid = errors.New("only HTTP status codes 100 - 599 are supported for DirectResponse") ErrRedirectUnsupportedStatus = errors.New("only HTTP status codes 301 and 302 are supported for redirect filters") ErrRedirectUnsupportedScheme = errors.New("only http and https are supported for the scheme in redirect filters") - ErrHTTPPathModifierDoubleReplace = errors.New("redirect filter cannot have a path modifier that supplies both fullPathReplace and prefixMatchReplace") - ErrHTTPPathModifierNoReplace = errors.New("redirect filter cannot have a path modifier that does not supply either fullPathReplace or prefixMatchReplace") + ErrHTTPPathModifierDoubleReplace = errors.New("redirect filter cannot have a path modifier that supplies more than one of fullPathReplace, prefixMatchReplace and regexMatchReplace") + ErrHTTPPathModifierNoReplace = errors.New("redirect filter cannot have a path modifier that does not supply either fullPathReplace, prefixMatchReplace or regexMatchReplace") + ErrHTTPPathRegexModifierNoSetting = errors.New("redirect filter cannot have a path modifier that does not supply either fullPathReplace, prefixMatchReplace or regexMatchReplace") ErrAddHeaderEmptyName = errors.New("header modifier filter cannot configure a header without a name to be added") ErrAddHeaderDuplicate = errors.New("header modifier filter attempts to add the same header more than once (case insensitive)") ErrRemoveHeaderDuplicate = errors.New("header modifier filter attempts to remove the same header more than once (case insensitive)") @@ -729,6 +730,18 @@ type UnstructuredRef struct { Object *unstructured.Unstructured `json:"object,omitempty" yaml:"object,omitempty"` } +// RegexMatchReplace defines the schema for modifying HTTP request path using regex. +// +// +k8s:deepcopy-gen=true +type RegexMatchReplace struct { + // Pattern matches a regular expression against the value of the HTTP Path.The regex string must + // adhere to the syntax documented in https://github.com/google/re2/wiki/Syntax. + Pattern string `json:"pattern" yaml:"pattern"` + // Substitution is an expression that replaces the matched portion.The expression may include numbered + // capture groups that adhere to syntax documented in https://github.com/google/re2/wiki/Syntax. + Substitution string `json:"substitution" yaml:"substitution"` +} + // CORS holds the Cross-Origin Resource Sharing (CORS) policy for the route. // // +k8s:deepcopy-gen=true @@ -1293,7 +1306,7 @@ func (r DirectResponse) Validate() error { // +k8s:deepcopy-gen=true type URLRewrite struct { // Path contains config for rewriting the path of the request. - Path *HTTPPathModifier `json:"path,omitempty" yaml:"path,omitempty"` + Path *ExtendedHTTPPathModifier `json:"path,omitempty" yaml:"path,omitempty"` // Hostname configures the replacement of the request's hostname. Hostname *string `json:"hostname,omitempty" yaml:"hostname,omitempty"` } @@ -1375,6 +1388,42 @@ func (r HTTPPathModifier) Validate() error { return errs } +// ExtendedHTTPPathModifier holds instructions for how to modify the path of a request on a redirect response +// with both core gateway-api and extended envoy gateway capabilities +// +k8s:deepcopy-gen=true +type ExtendedHTTPPathModifier struct { + HTTPPathModifier `json:",inline" yaml:",inline"` + // RegexMatchReplace provides a regex to match an a replacement to perform on the path. + RegexMatchReplace *RegexMatchReplace `json:"regexMatchReplace" yaml:"regexMatchReplace"` +} + +// Validate the fields within the HTTPPathModifier structure +func (r ExtendedHTTPPathModifier) Validate() error { + var errs error + + rewrites := []bool{r.RegexMatchReplace != nil, r.PrefixMatchReplace != nil, r.FullReplace != nil} + rwc := 0 + for _, rw := range rewrites { + if rw { + rwc++ + } + } + + if rwc > 1 { + errs = errors.Join(errs, ErrHTTPPathModifierDoubleReplace) + } + + if r.FullReplace == nil && r.PrefixMatchReplace == nil && r.RegexMatchReplace == nil { + errs = errors.Join(errs, ErrHTTPPathModifierNoReplace) + } + + if r.RegexMatchReplace != nil && (r.RegexMatchReplace.Pattern == "" || r.RegexMatchReplace.Substitution == "") { + errs = errors.Join(errs, ErrHTTPPathModifierNoReplace) + } + + return errs +} + // StringMatch holds the various match conditions. // Only one of Exact, Prefix, SafeRegex or Distinct can be set. // +k8s:deepcopy-gen=true diff --git a/internal/ir/xds_test.go b/internal/ir/xds_test.go index 882aa090e552..14b624f22f3f 100644 --- a/internal/ir/xds_test.go +++ b/internal/ir/xds_test.go @@ -309,8 +309,10 @@ var ( }, URLRewrite: &URLRewrite{ Hostname: ptr.To("rewrite.example.com"), - Path: &HTTPPathModifier{ - FullReplace: ptr.To("/rewrite"), + Path: &ExtendedHTTPPathModifier{ + HTTPPathModifier: HTTPPathModifier{ + FullReplace: ptr.To("/rewrite"), + }, }, }, } @@ -323,9 +325,11 @@ var ( }, URLRewrite: &URLRewrite{ Hostname: ptr.To("rewrite.example.com"), - Path: &HTTPPathModifier{ - FullReplace: ptr.To("/rewrite"), - PrefixMatchReplace: ptr.To("/rewrite"), + Path: &ExtendedHTTPPathModifier{ + HTTPPathModifier: HTTPPathModifier{ + FullReplace: ptr.To("/rewrite"), + PrefixMatchReplace: ptr.To("/rewrite"), + }, }, }, } diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index 1639262be2e6..0b964d5fc41e 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -909,6 +909,27 @@ func (in *ExtProc) DeepCopy() *ExtProc { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExtendedHTTPPathModifier) DeepCopyInto(out *ExtendedHTTPPathModifier) { + *out = *in + in.HTTPPathModifier.DeepCopyInto(&out.HTTPPathModifier) + if in.RegexMatchReplace != nil { + in, out := &in.RegexMatchReplace, &out.RegexMatchReplace + *out = new(RegexMatchReplace) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtendedHTTPPathModifier. +func (in *ExtendedHTTPPathModifier) DeepCopy() *ExtendedHTTPPathModifier { + if in == nil { + return nil + } + out := new(ExtendedHTTPPathModifier) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FaultInjection) DeepCopyInto(out *FaultInjection) { *out = *in @@ -2296,6 +2317,21 @@ func (in *Redirect) DeepCopy() *Redirect { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegexMatchReplace) DeepCopyInto(out *RegexMatchReplace) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegexMatchReplace. +func (in *RegexMatchReplace) DeepCopy() *RegexMatchReplace { + if in == nil { + return nil + } + out := new(RegexMatchReplace) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceMetadata) DeepCopyInto(out *ResourceMetadata) { *out = *in @@ -3136,7 +3172,7 @@ func (in *URLRewrite) DeepCopyInto(out *URLRewrite) { *out = *in if in.Path != nil { in, out := &in.Path, &out.Path - *out = new(HTTPPathModifier) + *out = new(ExtendedHTTPPathModifier) (*in).DeepCopyInto(*out) } if in.Hostname != nil { diff --git a/internal/provider/kubernetes/controller.go b/internal/provider/kubernetes/controller.go index a07ca6ab1204..24543fbd4a58 100644 --- a/internal/provider/kubernetes/controller.go +++ b/internal/provider/kubernetes/controller.go @@ -148,6 +148,9 @@ type resourceMappings struct { // The key is the namespaced name, group and kind of the filter and the value is the // unstructured form of the resource. extensionRefFilters map[utils.NamespacedNameWithGroupKind]unstructured.Unstructured + // httpRouteFilters is a map of HTTPRouteFilters, where the key is the namespaced name, + // group and kind of the HTTPFilter. + httpRouteFilters map[utils.NamespacedNameWithGroupKind]*egv1a1.HTTPRouteFilter } func newResourceMapping() *resourceMappings { @@ -161,6 +164,7 @@ func newResourceMapping() *resourceMappings { allAssociatedUDPRoutes: sets.New[string](), allAssociatedBackendRefs: sets.New[gwapiv1.BackendObjectReference](), extensionRefFilters: map[utils.NamespacedNameWithGroupKind]unstructured.Unstructured{}, + httpRouteFilters: map[utils.NamespacedNameWithGroupKind]*egv1a1.HTTPRouteFilter{}, } } diff --git a/internal/provider/kubernetes/filters.go b/internal/provider/kubernetes/filters.go index 985990fef147..11e62271d5ad 100644 --- a/internal/provider/kubernetes/filters.go +++ b/internal/provider/kubernetes/filters.go @@ -10,6 +10,8 @@ import ( "fmt" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" ) func (r *gatewayAPIReconciler) getExtensionRefFilters(ctx context.Context) ([]unstructured.Unstructured, error) { @@ -43,3 +45,12 @@ func (r *gatewayAPIReconciler) getExtensionRefFilters(ctx context.Context) ([]un return resourceItems, nil } + +func (r *gatewayAPIReconciler) getHTTPFilters(ctx context.Context) ([]egv1a1.HTTPRouteFilter, error) { + httpFilterList := new(egv1a1.HTTPRouteFilterList) + if err := r.client.List(ctx, httpFilterList); err != nil { + return nil, fmt.Errorf("failed to list HTTPRouteFilters: %w", err) + } + + return httpFilterList.Items, nil +} diff --git a/internal/provider/kubernetes/routes.go b/internal/provider/kubernetes/routes.go index d298e7e0e465..a9835728b37b 100644 --- a/internal/provider/kubernetes/routes.go +++ b/internal/provider/kubernetes/routes.go @@ -16,6 +16,7 @@ import ( gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/envoyproxy/gateway/internal/gatewayapi" "github.com/envoyproxy/gateway/internal/gatewayapi/resource" "github.com/envoyproxy/gateway/internal/utils" @@ -231,6 +232,15 @@ func (r *gatewayAPIReconciler) processHTTPRoutes(ctx context.Context, gatewayNam ) error { httpRouteList := &gwapiv1.HTTPRouteList{} + httpFilters, err := r.getHTTPFilters(ctx) + if err != nil { + return err + } + for i := range httpFilters { + filter := httpFilters[i] + resourceMap.httpRouteFilters[utils.GetNamespacedNameWithGroupKind(&filter)] = &filter + } + extensionRefFilters, err := r.getExtensionRefFilters(ctx) if err != nil { return err @@ -385,17 +395,29 @@ func (r *gatewayAPIReconciler) processHTTPRoutes(ctx context.Context, gatewayNam Kind: string(filter.ExtensionRef.Kind), }, } - extRefFilter, ok := resourceMap.extensionRefFilters[key] - if !ok { - r.log.Error( - errors.New("filter not found; bypassing rule"), - "Filter not found; bypassing rule", - "name", filter.ExtensionRef.Name, - "index", i) - continue - } - resourceTree.ExtensionRefFilters = append(resourceTree.ExtensionRefFilters, extRefFilter) + switch string(filter.ExtensionRef.Kind) { + case egv1a1.KindHTTPRouteFilter: + httpFilter, ok := resourceMap.httpRouteFilters[key] + if !ok { + r.log.Error(err, "HTTPRouteFilters not found; bypassing rule", "index", i) + continue + } + + resourceTree.HTTPRouteFilters = append(resourceTree.HTTPRouteFilters, httpFilter) + default: + extRefFilter, ok := resourceMap.extensionRefFilters[key] + if !ok { + r.log.Error( + errors.New("filter not found; bypassing rule"), + "Filter not found; bypassing rule", + "name", filter.ExtensionRef.Name, + "index", i) + continue + } + + resourceTree.ExtensionRefFilters = append(resourceTree.ExtensionRefFilters, extRefFilter) + } } } } diff --git a/internal/xds/translator/route.go b/internal/xds/translator/route.go index 42f17ff94f18..a8ec4a291d5b 100644 --- a/internal/xds/translator/route.go +++ b/internal/xds/translator/route.go @@ -386,14 +386,15 @@ func buildXdsURLRewriteAction(destName string, urlRewrite *ir.URLRewrite, pathMa } if urlRewrite.Path != nil { - if urlRewrite.Path.FullReplace != nil { + switch { + case urlRewrite.Path.FullReplace != nil: routeAction.RegexRewrite = &matcherv3.RegexMatchAndSubstitute{ Pattern: &matcherv3.RegexMatcher{ Regex: "^/.*$", }, Substitution: *urlRewrite.Path.FullReplace, } - } else if urlRewrite.Path.PrefixMatchReplace != nil { + case urlRewrite.Path.PrefixMatchReplace != nil: // Circumvent the case of "//" when the replace string is "/" // An empty replace string does not seem to solve the issue so we are using // a regex match and replace instead @@ -406,6 +407,13 @@ func buildXdsURLRewriteAction(destName string, urlRewrite *ir.URLRewrite, pathMa // and the urlRewrite.Path.PrefixMatchReplace suffix with / the upstream will get unwanted / routeAction.PrefixRewrite = strings.TrimSuffix(*urlRewrite.Path.PrefixMatchReplace, "/") } + case urlRewrite.Path.RegexMatchReplace != nil: + routeAction.RegexRewrite = &matcherv3.RegexMatchAndSubstitute{ + Pattern: &matcherv3.RegexMatcher{ + Regex: urlRewrite.Path.RegexMatchReplace.Pattern, + }, + Substitution: urlRewrite.Path.RegexMatchReplace.Substitution, + } } } diff --git a/internal/xds/translator/testdata/in/xds-ir/http-route-rewrite-url-regex.yaml b/internal/xds/translator/testdata/in/xds-ir/http-route-rewrite-url-regex.yaml new file mode 100644 index 000000000000..0389201186eb --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/http-route-rewrite-url-regex.yaml @@ -0,0 +1,26 @@ +name: "http-route" +http: +- name: "first-listener" + address: "0.0.0.0" + port: 10080 + hostnames: + - "*" + path: + mergeSlashes: true + escapedSlashesAction: UnescapeAndRedirect + routes: + - name: "rewrite-route" + pathMatch: + prefix: "/origin" + hostname: gateway.envoyproxy.io + destination: + name: "rewrite-route" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 + urlRewrite: + path: + regexMatchReplace: + pattern: '^/service/([^/]+)(/.*)$' + substitution: '\2/instance/\1' diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.clusters.yaml new file mode 100644 index 000000000000..3a2b7308d8e1 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.clusters.yaml @@ -0,0 +1,17 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: rewrite-route + lbPolicy: LEAST_REQUEST + name: rewrite-route + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + type: EDS diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.endpoints.yaml new file mode 100644 index 000000000000..ca1ef21c989e --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.endpoints.yaml @@ -0,0 +1,12 @@ +- clusterName: rewrite-route + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: rewrite-route/backend/0 diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.listeners.yaml new file mode 100644 index 000000000000..c3fb113017a9 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.listeners.yaml @@ -0,0 +1,34 @@ +- address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + commonHttpProtocolOptions: + headersWithUnderscoresAction: REJECT_REQUEST + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 + maxConcurrentStreams: 100 + httpFilters: + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + suppressEnvoyHeaders: true + mergeSlashes: true + normalizePath: true + pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: first-listener + serverHeaderTransformation: PASS_THROUGH + statPrefix: http-10080 + useRemoteAddress: true + name: first-listener + name: first-listener + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.routes.yaml new file mode 100644 index 000000000000..20d4e99ef68c --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-route-rewrite-url-regex.routes.yaml @@ -0,0 +1,18 @@ +- ignorePortInHostMatching: true + name: first-listener + virtualHosts: + - domains: + - gateway.envoyproxy.io + name: first-listener/gateway_envoyproxy_io + routes: + - match: + pathSeparatedPrefix: /origin + name: rewrite-route + route: + cluster: rewrite-route + regexRewrite: + pattern: + regex: ^/service/([^/]+)(/.*)$ + substitution: \2/instance/\1 + upgradeConfigs: + - upgradeType: websocket diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 70b7608406e6..92edccbbe238 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -1905,7 +1905,7 @@ _Appears in:_ | Field | Type | Required | Description | | --- | --- | --- | --- | | `type` | _[HTTPPathModifierType](#httppathmodifiertype)_ | true | | -| `replaceRegexMatch` | _[ReplaceRegexMatch](#replaceregexmatch)_ | true | ReplaceRegexMatch defines a path regex rewrite. The path portions matched by the regex pattern are replaced by the defined substitution.
https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-regex-rewrite
Some examples:
(1) replaceRegexMatch:
pattern: ^/service/([^/]+)(/.*)$
substitution: \2/instance/\1
Would transform /service/foo/v1/api into /v1/api/instance/foo.
(2) replaceRegexMatch:
pattern: one
substitution: two
Would transform /xxx/one/yyy/one/zzz into /xxx/two/yyy/two/zzz.
(3) replaceRegexMatch:
pattern: ^(.*?)one(.*)$
substitution: \1two\2
Would transform /xxx/one/yyy/one/zzz into /xxx/two/yyy/one/zzz.
(3) replaceRegexMatch:
pattern: (?i)/xxx/
substitution: /yyy/
Would transform path /aaa/XxX/bbb into /aaa/yyy/bbb (case-insensitive). | +| `replaceRegexMatch` | _[ReplaceRegexMatch](#replaceregexmatch)_ | false | ReplaceRegexMatch defines a path regex rewrite. The path portions matched by the regex pattern are replaced by the defined substitution.
https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-regex-rewrite
Some examples:
(1) replaceRegexMatch:
pattern: ^/service/([^/]+)(/.*)$
substitution: \2/instance/\1
Would transform /service/foo/v1/api into /v1/api/instance/foo.
(2) replaceRegexMatch:
pattern: one
substitution: two
Would transform /xxx/one/yyy/one/zzz into /xxx/two/yyy/two/zzz.
(3) replaceRegexMatch:
pattern: ^(.*?)one(.*)$
substitution: \1two\2
Would transform /xxx/one/yyy/one/zzz into /xxx/two/yyy/one/zzz.
(3) replaceRegexMatch:
pattern: (?i)/xxx/
substitution: /yyy/
Would transform path /aaa/XxX/bbb into /aaa/yyy/bbb (case-insensitive). | #### HTTPPathModifierType diff --git a/site/content/en/latest/tasks/traffic/http-urlrewrite.md b/site/content/en/latest/tasks/traffic/http-urlrewrite.md index 67915f93fcfe..a643d775a570 100644 --- a/site/content/en/latest/tasks/traffic/http-urlrewrite.md +++ b/site/content/en/latest/tasks/traffic/http-urlrewrite.md @@ -275,6 +275,160 @@ $ curl -L -vvv --header "Host: path.rewrite.example" "http://${GATEWAY_HOST}/get You can see that the `X-Envoy-Original-Path` is `/get/origin/path/extra`, but the actual path is `/force/replace/fullpath`. +## Rewrite URL Path with Regex + +In addition to core Gateway-API rewrite options, Envoy Gateway supports extended rewrite options through the [HTTPRouteFilter][] API. +The `HTTPRouteFilter` API can be configured to use [RE2][]-compatible regex matchers and substitutions to rewrite a portion of the url. +In the example below, requests sent to `http://${GATEWAY_HOST}/service/xxx/yyy` (where `xxx` is a single path portion and `yyy` is one or more path portions) +are rewritten to `http://${GATEWAY_HOST}/yyy/instance/xxx`. The entire path is matched and rewritten using capture groups. + +{{< tabpane text=true >}} +{{% tab header="Apply from stdin" %}} + +```shell +cat <}} + +The HTTPRoute status should indicate that it has been accepted and is bound to the example Gateway. + +```shell +kubectl get httproute/http-filter-url-rewrite -o yaml +``` + +Querying `http://${GATEWAY_HOST}/get/origin/path/extra` should rewrite the request to +`http://${GATEWAY_HOST}/force/replace/fullpath`. + +```console +$ curl -L -vvv --header "Host: path.regex.rewrite.example" "http://${GATEWAY_HOST}/get/origin/path/extra" +... +> GET /service/foo/v1/api HTTP/1.1 +> Host: path.regex.rewrite.example +> User-Agent: curl/8.7.1 +> Accept: */* +> +* Request completely sent off +< HTTP/1.1 200 OK +< content-type: application/json +< x-content-type-options: nosniff +< date: Mon, 16 Sep 2024 18:49:48 GMT +< content-length: 482 +< +{ + "path": "/v1/api/instance/foo", + "host": "path.regex.rewrite.example", + "method": "GET", + "proto": "HTTP/1.1", + "headers": { + "Accept": [ + "*/*" + ], + "User-Agent": [ + "curl/8.7.1" + ], + "X-Envoy-Internal": [ + "true" + ], + "X-Forwarded-For": [ + "10.244.0.37" + ], + "X-Forwarded-Proto": [ + "http" + ], + "X-Request-Id": [ + "24a5958f-1bfa-4694-a9c1-807d5139a18a" + ] + }, + "namespace": "default", + "ingress": "", + "service": "", + "pod": "backend-765694d47f-lzmpm" +... +``` + +You can see that the path is rewritten from `/service/foo/v1/api`, to `/v1/api/instance/foo`. + ## Rewrite Host Name You can configure to rewrite the hostname like below. In this example, any requests sent to @@ -402,3 +556,5 @@ $ curl -L -vvv --header "Host: path.rewrite.example" "http://${GATEWAY_HOST}/get You can see that the `X-Forwarded-Host` is `path.rewrite.example`, but the actual host is `envoygateway.io`. [HTTPURLRewriteFilter]: https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io/v1.HTTPURLRewriteFilter +[HTTPRouteFilter]: ../../../api/extension_types#httproutefilter +[RE2]: https://github.com/google/re2/wiki/Syntax \ No newline at end of file diff --git a/site/content/zh/latest/api/extension_types.md b/site/content/zh/latest/api/extension_types.md index 70b7608406e6..92edccbbe238 100644 --- a/site/content/zh/latest/api/extension_types.md +++ b/site/content/zh/latest/api/extension_types.md @@ -1905,7 +1905,7 @@ _Appears in:_ | Field | Type | Required | Description | | --- | --- | --- | --- | | `type` | _[HTTPPathModifierType](#httppathmodifiertype)_ | true | | -| `replaceRegexMatch` | _[ReplaceRegexMatch](#replaceregexmatch)_ | true | ReplaceRegexMatch defines a path regex rewrite. The path portions matched by the regex pattern are replaced by the defined substitution.
https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-regex-rewrite
Some examples:
(1) replaceRegexMatch:
pattern: ^/service/([^/]+)(/.*)$
substitution: \2/instance/\1
Would transform /service/foo/v1/api into /v1/api/instance/foo.
(2) replaceRegexMatch:
pattern: one
substitution: two
Would transform /xxx/one/yyy/one/zzz into /xxx/two/yyy/two/zzz.
(3) replaceRegexMatch:
pattern: ^(.*?)one(.*)$
substitution: \1two\2
Would transform /xxx/one/yyy/one/zzz into /xxx/two/yyy/one/zzz.
(3) replaceRegexMatch:
pattern: (?i)/xxx/
substitution: /yyy/
Would transform path /aaa/XxX/bbb into /aaa/yyy/bbb (case-insensitive). | +| `replaceRegexMatch` | _[ReplaceRegexMatch](#replaceregexmatch)_ | false | ReplaceRegexMatch defines a path regex rewrite. The path portions matched by the regex pattern are replaced by the defined substitution.
https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-regex-rewrite
Some examples:
(1) replaceRegexMatch:
pattern: ^/service/([^/]+)(/.*)$
substitution: \2/instance/\1
Would transform /service/foo/v1/api into /v1/api/instance/foo.
(2) replaceRegexMatch:
pattern: one
substitution: two
Would transform /xxx/one/yyy/one/zzz into /xxx/two/yyy/two/zzz.
(3) replaceRegexMatch:
pattern: ^(.*?)one(.*)$
substitution: \1two\2
Would transform /xxx/one/yyy/one/zzz into /xxx/two/yyy/one/zzz.
(3) replaceRegexMatch:
pattern: (?i)/xxx/
substitution: /yyy/
Would transform path /aaa/XxX/bbb into /aaa/yyy/bbb (case-insensitive). | #### HTTPPathModifierType diff --git a/test/cel-validation/httproutefilter_test.go b/test/cel-validation/httproutefilter_test.go new file mode 100644 index 000000000000..5d82f3c77663 --- /dev/null +++ b/test/cel-validation/httproutefilter_test.go @@ -0,0 +1,121 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build celvalidation +// +build celvalidation + +package celvalidation + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" +) + +func TestHTTPRouteFilter(t *testing.T) { + ctx := context.Background() + baseHTTPRouteFilter := egv1a1.HTTPRouteFilter{ + ObjectMeta: metav1.ObjectMeta{ + Name: "httproutefilter", + Namespace: metav1.NamespaceDefault, + }, + Spec: egv1a1.HTTPRouteFilterSpec{}, + } + + cases := []struct { + desc string + mutate func(httproutefilter *egv1a1.HTTPRouteFilter) + mutateStatus func(httproutefilter *egv1a1.HTTPRouteFilter) + wantErrors []string + }{ + { + desc: "Valid RegexHTTPPathModifier", + mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { + httproutefilter.Spec = egv1a1.HTTPRouteFilterSpec{ + URLRewrite: &egv1a1.HTTPURLRewriteFilter{ + Path: &egv1a1.HTTPPathModifier{ + Type: egv1a1.RegexHTTPPathModifier, + ReplaceRegexMatch: &egv1a1.ReplaceRegexMatch{ + Pattern: "foo", + Substitution: "bar", + }, + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "invalid RegexHTTPPathModifier missing settings", + mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { + httproutefilter.Spec = egv1a1.HTTPRouteFilterSpec{ + URLRewrite: &egv1a1.HTTPURLRewriteFilter{ + Path: &egv1a1.HTTPPathModifier{ + Type: egv1a1.RegexHTTPPathModifier, + }, + }, + } + }, + wantErrors: []string{"spec.urlRewrite.path: Invalid value: \"object\": If HTTPPathModifier type is ReplaceRegexMatch, replaceRegexMatch field needs to be set."}, + }, + { + desc: "invalid RegexHTTPPathModifier missing pattern and substitution", + mutate: func(httproutefilter *egv1a1.HTTPRouteFilter) { + httproutefilter.Spec = egv1a1.HTTPRouteFilterSpec{ + URLRewrite: &egv1a1.HTTPURLRewriteFilter{ + Path: &egv1a1.HTTPPathModifier{ + Type: egv1a1.RegexHTTPPathModifier, + ReplaceRegexMatch: &egv1a1.ReplaceRegexMatch{ + Pattern: "", + Substitution: "", + }, + }, + }, + } + }, + wantErrors: []string{ + "spec.urlRewrite.path.replaceRegexMatch.substitution: Invalid value: \"\": spec.urlRewrite.path.replaceRegexMatch.substitution in body should be at least 1 chars long", + "spec.urlRewrite.path.replaceRegexMatch.pattern: Invalid value: \"\": spec.urlRewrite.path.replaceRegexMatch.pattern in body should be at least 1 chars long", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + httpRouteFilter := baseHTTPRouteFilter.DeepCopy() + httpRouteFilter.Name = fmt.Sprintf("httpRouteFilter-%v", time.Now().UnixNano()) + + if tc.mutate != nil { + tc.mutate(httpRouteFilter) + } + err := c.Create(ctx, httpRouteFilter) + + if tc.mutateStatus != nil { + tc.mutateStatus(httpRouteFilter) + err = c.Status().Update(ctx, httpRouteFilter) + } + + if (len(tc.wantErrors) != 0) != (err != nil) { + t.Fatalf("Unexpected response while creating HTTPRouteFilter; got err=\n%v\n;want error=%v", err, tc.wantErrors) + } + + var missingErrorStrings []string + for _, wantError := range tc.wantErrors { + if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(wantError)) { + missingErrorStrings = append(missingErrorStrings, wantError) + } + } + if len(missingErrorStrings) != 0 { + t.Errorf("Unexpected response while creating HTTPRouteFilter; got err=\n%v\n;missing strings within error=%q", err, missingErrorStrings) + } + }) + } +} diff --git a/test/e2e/testdata/httproute-rewrite-regex-path.yaml b/test/e2e/testdata/httproute-rewrite-regex-path.yaml new file mode 100644 index 000000000000..821550d73759 --- /dev/null +++ b/test/e2e/testdata/httproute-rewrite-regex-path.yaml @@ -0,0 +1,35 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: rewrite-regex-path + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: / + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: regex-path-rewrite + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: HTTPRouteFilter +metadata: + name: regex-path-rewrite + namespace: gateway-conformance-infra +spec: + urlRewrite: + path: + type: ReplaceRegexMatch + replaceRegexMatch: + pattern: '^/service/([^/]+)(/.*)$' + substitution: '\2/instance/\1' diff --git a/test/e2e/tests/httproute_rewrite_regex_path.go b/test/e2e/tests/httproute_rewrite_regex_path.go new file mode 100644 index 000000000000..975d2ec88680 --- /dev/null +++ b/test/e2e/tests/httproute_rewrite_regex_path.go @@ -0,0 +1,59 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build e2e +// +build e2e + +package tests + +import ( + "testing" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" +) + +func init() { + ConformanceTests = append(ConformanceTests, HTTPRouteRewriteRegexPath) +} + +var HTTPRouteRewriteRegexPath = suite.ConformanceTest{ + ShortName: "HTTPRouteRewriteRegexPath", + Description: "An HTTPRoute with path rewrite filter to replace a regex match", + Manifests: []string{"testdata/httproute-rewrite-regex-path.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "rewrite-regex-path", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) + + testCases := []http.ExpectedResponse{ + { + Request: http.Request{ + Path: "/service/foo/v1/api", + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Path: "/v1/api/instance/foo", + }, + }, + Backend: "infra-backend-v1", + Namespace: ns, + }, + } + for i := range testCases { + // Declare tc here to avoid loop variable + // reuse issues across parallel tests. + tc := testCases[i] + t.Run(tc.GetTestCaseName(i), func(t *testing.T) { + t.Parallel() + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, tc) + }) + } + }, +} diff --git a/test/helm/gateway-helm/certjen-custom-scheduling.out.yaml b/test/helm/gateway-helm/certjen-custom-scheduling.out.yaml index 0bd873a34b9a..c280e54ca94d 100644 --- a/test/helm/gateway-helm/certjen-custom-scheduling.out.yaml +++ b/test/helm/gateway-helm/certjen-custom-scheduling.out.yaml @@ -127,6 +127,7 @@ rules: - securitypolicies - envoyextensionpolicies - backends + - httproutefilters verbs: - get - list diff --git a/test/helm/gateway-helm/control-plane-with-pdb.out.yaml b/test/helm/gateway-helm/control-plane-with-pdb.out.yaml index cd7ff1a53dcd..3db52f2bcbee 100644 --- a/test/helm/gateway-helm/control-plane-with-pdb.out.yaml +++ b/test/helm/gateway-helm/control-plane-with-pdb.out.yaml @@ -142,6 +142,7 @@ rules: - securitypolicies - envoyextensionpolicies - backends + - httproutefilters verbs: - get - list diff --git a/test/helm/gateway-helm/default-config.out.yaml b/test/helm/gateway-helm/default-config.out.yaml index 147c0f8ba707..0dd66b5c2099 100644 --- a/test/helm/gateway-helm/default-config.out.yaml +++ b/test/helm/gateway-helm/default-config.out.yaml @@ -127,6 +127,7 @@ rules: - securitypolicies - envoyextensionpolicies - backends + - httproutefilters verbs: - get - list diff --git a/test/helm/gateway-helm/deployment-custom-topology.out.yaml b/test/helm/gateway-helm/deployment-custom-topology.out.yaml index 7fdfef5e53e7..b9fc662515f6 100644 --- a/test/helm/gateway-helm/deployment-custom-topology.out.yaml +++ b/test/helm/gateway-helm/deployment-custom-topology.out.yaml @@ -127,6 +127,7 @@ rules: - securitypolicies - envoyextensionpolicies - backends + - httproutefilters verbs: - get - list diff --git a/test/helm/gateway-helm/deployment-images-config.out.yaml b/test/helm/gateway-helm/deployment-images-config.out.yaml index 5a2df408fe54..4da6c9a57f3c 100644 --- a/test/helm/gateway-helm/deployment-images-config.out.yaml +++ b/test/helm/gateway-helm/deployment-images-config.out.yaml @@ -127,6 +127,7 @@ rules: - securitypolicies - envoyextensionpolicies - backends + - httproutefilters verbs: - get - list diff --git a/test/helm/gateway-helm/envoy-gateway-config.out.yaml b/test/helm/gateway-helm/envoy-gateway-config.out.yaml index 233334d59721..056a0bde7d0b 100644 --- a/test/helm/gateway-helm/envoy-gateway-config.out.yaml +++ b/test/helm/gateway-helm/envoy-gateway-config.out.yaml @@ -129,6 +129,7 @@ rules: - securitypolicies - envoyextensionpolicies - backends + - httproutefilters verbs: - get - list diff --git a/test/helm/gateway-helm/global-images-config.out.yaml b/test/helm/gateway-helm/global-images-config.out.yaml index 95b98165d124..5eea20550703 100644 --- a/test/helm/gateway-helm/global-images-config.out.yaml +++ b/test/helm/gateway-helm/global-images-config.out.yaml @@ -131,6 +131,7 @@ rules: - securitypolicies - envoyextensionpolicies - backends + - httproutefilters verbs: - get - list