diff --git a/pkg/ingress/kube/annotations/annotations.go b/pkg/ingress/kube/annotations/annotations.go index cd326783f..a64e49a52 100644 --- a/pkg/ingress/kube/annotations/annotations.go +++ b/pkg/ingress/kube/annotations/annotations.go @@ -67,6 +67,8 @@ type Ingress struct { IgnoreCase *IgnoreCaseConfig Match *MatchConfig + + HeaderControl *HeaderControlConfig } func (i *Ingress) NeedRegexMatch() bool { @@ -138,6 +140,7 @@ func NewAnnotationHandlerManager() AnnotationHandler { destination{}, ignoreCaseMatching{}, match{}, + headerControl{}, }, gatewayHandlers: []GatewayHandler{ downstreamTLS{}, @@ -154,6 +157,7 @@ func NewAnnotationHandlerManager() AnnotationHandler { fallback{}, ignoreCaseMatching{}, match{}, + headerControl{}, }, trafficPolicyHandlers: []TrafficPolicyHandler{ upstreamTLS{}, diff --git a/pkg/ingress/kube/annotations/canary.go b/pkg/ingress/kube/annotations/canary.go index af316cdcf..7b6f4b5ef 100644 --- a/pkg/ingress/kube/annotations/canary.go +++ b/pkg/ingress/kube/annotations/canary.go @@ -106,6 +106,8 @@ func ApplyByWeight(canary, route *networking.HTTPRoute, canaryIngress *Ingress) // We will process total weight in the end. route.Route = append(route.Route, canary.Route[0]) + // canary route use the header control applied on itself. + headerControl{}.ApplyRoute(canary, canaryIngress) // Move route level to destination level canary.Route[0].Headers = canary.Headers @@ -166,6 +168,10 @@ func ApplyByHeader(canary, route *networking.HTTPRoute, canaryIngress *Ingress) } } + canary.Headers = nil + // canary route use the header control applied on itself. + headerControl{}.ApplyRoute(canary, canaryIngress) + // First add normal route cluster canary.Route[0].FallbackClusters = append(canary.Route[0].FallbackClusters, route.Route[0].Destination.DeepCopy()) diff --git a/pkg/ingress/kube/annotations/header_control.go b/pkg/ingress/kube/annotations/header_control.go new file mode 100644 index 000000000..b3dc08f16 --- /dev/null +++ b/pkg/ingress/kube/annotations/header_control.go @@ -0,0 +1,160 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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" + + networking "istio.io/api/networking/v1alpha3" + + . "github.com/alibaba/higress/pkg/ingress/log" +) + +const ( + // request + requestHeaderAdd = "request-header-control-add" + requestHeaderUpdate = "request-header-control-update" + requestHeaderRemove = "request-header-control-remove" + + // response + responseHeaderAdd = "response-header-control-add" + responseHeaderUpdate = "response-header-control-update" + responseHeaderRemove = "response-header-control-remove" +) + +var ( + _ Parser = headerControl{} + _ RouteHandler = headerControl{} +) + +type HeaderOperation struct { + Add map[string]string + Update map[string]string + Remove []string +} + +// HeaderControlConfig enforces header operations on route level. +// Note: Canary route don't use header control applied on the normal route. +type HeaderControlConfig struct { + Request *HeaderOperation + Response *HeaderOperation +} + +type headerControl struct{} + +func (h headerControl) Parse(annotations Annotations, config *Ingress, _ *GlobalContext) error { + if !needHeaderControlConfig(annotations) { + return nil + } + + config.HeaderControl = &HeaderControlConfig{} + + var requestAdd map[string]string + var requestUpdate map[string]string + var requestRemove []string + if add, err := annotations.ParseStringForHigress(requestHeaderAdd); err == nil { + requestAdd = convertAddOrUpdate(add) + } + if update, err := annotations.ParseStringForHigress(requestHeaderUpdate); err == nil { + requestUpdate = convertAddOrUpdate(update) + } + if remove, err := annotations.ParseStringForHigress(requestHeaderRemove); err == nil { + requestRemove = splitBySeparator(remove, ",") + } + if len(requestAdd) > 0 || len(requestUpdate) > 0 || len(requestRemove) > 0 { + config.HeaderControl.Request = &HeaderOperation{ + Add: requestAdd, + Update: requestUpdate, + Remove: requestRemove, + } + } + + var responseAdd map[string]string + var responseUpdate map[string]string + var responseRemove []string + if add, err := annotations.ParseStringForHigress(responseHeaderAdd); err == nil { + responseAdd = convertAddOrUpdate(add) + } + if update, err := annotations.ParseStringForHigress(responseHeaderUpdate); err == nil { + responseUpdate = convertAddOrUpdate(update) + } + if remove, err := annotations.ParseStringForHigress(responseHeaderRemove); err == nil { + responseRemove = splitBySeparator(remove, ",") + } + if len(responseAdd) > 0 || len(responseUpdate) > 0 || len(responseRemove) > 0 { + config.HeaderControl.Response = &HeaderOperation{ + Add: responseAdd, + Update: responseUpdate, + Remove: responseRemove, + } + } + + return nil +} + +func (h headerControl) ApplyRoute(route *networking.HTTPRoute, config *Ingress) { + headerControlConfig := config.HeaderControl + if headerControlConfig == nil { + return + } + + headers := &networking.Headers{ + Request: &networking.Headers_HeaderOperations{}, + Response: &networking.Headers_HeaderOperations{}, + } + if headerControlConfig.Request != nil { + headers.Request.Add = headerControlConfig.Request.Add + headers.Request.Set = headerControlConfig.Request.Update + headers.Request.Remove = headerControlConfig.Request.Remove + } + + if headerControlConfig.Response != nil { + headers.Response.Add = headerControlConfig.Response.Add + headers.Response.Set = headerControlConfig.Response.Update + headers.Response.Remove = headerControlConfig.Response.Remove + } + + route.Headers = headers +} + +func needHeaderControlConfig(annotations Annotations) bool { + return annotations.HasHigress(requestHeaderAdd) || + annotations.HasHigress(requestHeaderUpdate) || + annotations.HasHigress(requestHeaderRemove) || + annotations.HasHigress(responseHeaderAdd) || + annotations.HasHigress(responseHeaderUpdate) || + annotations.HasHigress(responseHeaderRemove) +} + +func convertAddOrUpdate(headers string) map[string]string { + result := map[string]string{} + parts := strings.Split(headers, "\n") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + keyValue := strings.Fields(part) + if len(keyValue) != 2 { + IngressLog.Errorf("Header format %s is invalid.", keyValue) + continue + } + key := strings.TrimSpace(keyValue[0]) + value := strings.TrimSpace(keyValue[1]) + result[key] = value + } + return result +} diff --git a/pkg/ingress/kube/annotations/header_control_test.go b/pkg/ingress/kube/annotations/header_control_test.go new file mode 100644 index 000000000..9f022d062 --- /dev/null +++ b/pkg/ingress/kube/annotations/header_control_test.go @@ -0,0 +1,235 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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" + + networking "istio.io/api/networking/v1alpha3" +) + +func TestHeaderControlParse(t *testing.T) { + headerControl := &headerControl{} + inputCases := []struct { + input map[string]string + expect *HeaderControlConfig + }{ + {}, + { + input: map[string]string{ + buildHigressAnnotationKey(requestHeaderAdd): "one 1", + buildHigressAnnotationKey(responseHeaderAdd): "A a", + }, + expect: &HeaderControlConfig{ + Request: &HeaderOperation{ + Add: map[string]string{ + "one": "1", + }, + }, + Response: &HeaderOperation{ + Add: map[string]string{ + "A": "a", + }, + }, + }, + }, + { + input: map[string]string{ + buildHigressAnnotationKey(requestHeaderAdd): "one 1\n two 2\nthree 3 \n", + buildHigressAnnotationKey(requestHeaderUpdate): "two 2", + buildHigressAnnotationKey(requestHeaderRemove): "one, two,three\n", + buildHigressAnnotationKey(responseHeaderAdd): "A a\nB b\n", + buildHigressAnnotationKey(responseHeaderUpdate): "X x\nY y\n", + buildHigressAnnotationKey(responseHeaderRemove): "x", + }, + expect: &HeaderControlConfig{ + Request: &HeaderOperation{ + Add: map[string]string{ + "one": "1", + "two": "2", + "three": "3", + }, + Update: map[string]string{ + "two": "2", + }, + Remove: []string{"one", "two", "three"}, + }, + Response: &HeaderOperation{ + Add: map[string]string{ + "A": "a", + "B": "b", + }, + Update: map[string]string{ + "X": "x", + "Y": "y", + }, + Remove: []string{"x"}, + }, + }, + }, + } + + for _, inputCase := range inputCases { + t.Run("", func(t *testing.T) { + config := &Ingress{} + _ = headerControl.Parse(inputCase.input, config, nil) + if !reflect.DeepEqual(inputCase.expect, config.HeaderControl) { + t.Fatal("Should be equal") + } + }) + } +} + +func TestHeaderControlApplyRoute(t *testing.T) { + headerControl := headerControl{} + inputCases := []struct { + config *Ingress + input *networking.HTTPRoute + expect *networking.HTTPRoute + }{ + { + config: &Ingress{}, + input: &networking.HTTPRoute{}, + expect: &networking.HTTPRoute{}, + }, + { + config: &Ingress{ + HeaderControl: &HeaderControlConfig{}, + }, + input: &networking.HTTPRoute{}, + expect: &networking.HTTPRoute{ + Headers: &networking.Headers{ + Request: &networking.Headers_HeaderOperations{}, + Response: &networking.Headers_HeaderOperations{}, + }, + }, + }, + { + config: &Ingress{ + HeaderControl: &HeaderControlConfig{ + Request: &HeaderOperation{ + Add: map[string]string{ + "one": "1", + "two": "2", + "three": "3", + }, + Update: map[string]string{ + "two": "2", + }, + Remove: []string{"one", "two", "three"}, + }, + }, + }, + input: &networking.HTTPRoute{}, + expect: &networking.HTTPRoute{ + Headers: &networking.Headers{ + Request: &networking.Headers_HeaderOperations{ + Add: map[string]string{ + "one": "1", + "two": "2", + "three": "3", + }, + Set: map[string]string{ + "two": "2", + }, + Remove: []string{"one", "two", "three"}, + }, + Response: &networking.Headers_HeaderOperations{}, + }, + }, + }, + { + config: &Ingress{ + HeaderControl: &HeaderControlConfig{ + Response: &HeaderOperation{ + Add: map[string]string{ + "A": "a", + "B": "b", + }, + Update: map[string]string{ + "X": "x", + "Y": "y", + }, + Remove: []string{"x"}, + }, + }, + }, + input: &networking.HTTPRoute{}, + expect: &networking.HTTPRoute{ + Headers: &networking.Headers{ + Request: &networking.Headers_HeaderOperations{}, + Response: &networking.Headers_HeaderOperations{ + Add: map[string]string{ + "A": "a", + "B": "b", + }, + Set: map[string]string{ + "X": "x", + "Y": "y", + }, + Remove: []string{"x"}, + }, + }, + }, + }, + { + config: &Ingress{ + HeaderControl: &HeaderControlConfig{ + Request: &HeaderOperation{ + Update: map[string]string{ + "two": "2", + }, + Remove: []string{"one", "two", "three"}, + }, + Response: &HeaderOperation{ + Add: map[string]string{ + "A": "a", + "B": "b", + }, + Remove: []string{"x"}, + }, + }, + }, + input: &networking.HTTPRoute{}, + expect: &networking.HTTPRoute{ + Headers: &networking.Headers{ + Request: &networking.Headers_HeaderOperations{ + Set: map[string]string{ + "two": "2", + }, + Remove: []string{"one", "two", "three"}, + }, + Response: &networking.Headers_HeaderOperations{ + Add: map[string]string{ + "A": "a", + "B": "b", + }, + Remove: []string{"x"}, + }, + }, + }, + }, + } + + for _, inputCase := range inputCases { + t.Run("", func(t *testing.T) { + headerControl.ApplyRoute(inputCase.input, inputCase.config) + if !reflect.DeepEqual(inputCase.input, inputCase.expect) { + t.Fatal("Should be equal") + } + }) + } +} diff --git a/test/ingress/conformance/tests/httproute-request-header-control.go b/test/ingress/conformance/tests/httproute-request-header-control.go new file mode 100644 index 000000000..0c0f3c14a --- /dev/null +++ b/test/ingress/conformance/tests/httproute-request-header-control.go @@ -0,0 +1,130 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// 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 tests + +import ( + "testing" + + "github.com/alibaba/higress/test/ingress/conformance/utils/http" + "github.com/alibaba/higress/test/ingress/conformance/utils/suite" +) + +func init() { + HigressConformanceTests = append(HigressConformanceTests, HTTPRouteRequestHeaderControl) +} + +var HTTPRouteRequestHeaderControl = suite.ConformanceTest{ + ShortName: "HTTPRouteRequestHeaderControl", + Description: "A single Ingress in the higress-conformance-infra namespace controls the request header.", + Manifests: []string{"tests/httproute-request-header-control.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + testcases := []http.Assertion{ + { + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Path: "/foo1", + Host: "foo.com", + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Headers: map[string]string{ + "stage": "test", + }, + }, + }, + }, + + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + { + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Path: "/foo2", + Host: "foo.com", + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Headers: map[string]string{ + "stage": "test", + "canary": "true", + }, + }, + }, + }, + + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + { + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Path: "/foo3", + Host: "foo.com", + Headers: map[string]string{ + "stage": "test", + }, + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Headers: map[string]string{ + "stage": "pro", + "canary": "true", + }, + }, + }, + }, + + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + { + Request: http.AssertionRequest{ + ActualRequest: http.Request{ + Path: "/foo4", + Host: "foo.com", + Headers: map[string]string{ + "stage": "test", + }, + }, + ExpectedRequest: &http.ExpectedRequest{ + AbsentHeaders: []string{"stage"}, + }, + }, + + Response: http.AssertionResponse{ + ExpectedResponse: http.Response{ + StatusCode: 200, + }, + }, + }, + } + + t.Run("Request header control", func(t *testing.T) { + for _, testcase := range testcases { + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase) + } + }) + }, +} diff --git a/test/ingress/conformance/tests/httproute-request-header-control.yaml b/test/ingress/conformance/tests/httproute-request-header-control.yaml new file mode 100644 index 000000000..3b0cf83a9 --- /dev/null +++ b/test/ingress/conformance/tests/httproute-request-header-control.yaml @@ -0,0 +1,99 @@ +# Copyright (c) 2022 Alibaba Group Holding Ltd. +# +# 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. + +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + higress.io/request-header-control-add: stage test + name: httproute-request-header-control-add-one + namespace: higress-conformance-infra +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Exact + path: "/foo1" + backend: + service: + name: infra-backend-v1 + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + higress.io/request-header-control-add: | + stage test + canary true + name: httproute-request-header-control-add-more + namespace: higress-conformance-infra +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Exact + path: "/foo2" + backend: + service: + name: infra-backend-v1 + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + higress.io/request-header-control-update: stage pro + name: httproute-request-header-control-update + namespace: higress-conformance-infra +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Exact + path: "/foo3" + backend: + service: + name: infra-backend-v1 + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + higress.io/request-header-control-remove: stage + name: httproute-request-header-control-remove + namespace: higress-conformance-infra +spec: + ingressClassName: higress + rules: + - host: "foo.com" + http: + paths: + - pathType: Exact + path: "/foo4" + backend: + service: + name: infra-backend-v1 + port: + number: 8080