diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1d31510c..704ca64039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,14 @@ Adding a new version? You'll need three changes: irrelevant secrets (e.g: service account tokens) are not stored. This change is made to reduce memory usage of the cache. [#3047](https://github.com/Kong/kubernetes-ingress-controller/pull/3047) +- Services support annotations for connect, read, and write timeouts. + [#3121](https://github.com/Kong/kubernetes-ingress-controller/pull/3121) +- Services support annotations for retries. + [#3121](https://github.com/Kong/kubernetes-ingress-controller/pull/3121) +- Routes support annotations for headers. + [#3121](https://github.com/Kong/kubernetes-ingress-controller/pull/3121) +- Routes support annotations for path handling. + [#3121](https://github.com/Kong/kubernetes-ingress-controller/pull/3121) ### Fixed diff --git a/internal/annotations/annotations.go b/internal/annotations/annotations.go index 3f359de12c..ec8816a736 100644 --- a/internal/annotations/annotations.go +++ b/internal/annotations/annotations.go @@ -56,6 +56,12 @@ const ( ResponseBuffering = "/response-buffering" HostAliasesKey = "/host-aliases" RegexPrefixKey = "/regex-prefix" + ConnectTimeoutKey = "/connect-timeout" + WriteTimeoutKey = "/write-timeout" + ReadTimeoutKey = "/read-timeout" + RetriesKey = "/retries" + HeadersKey = "/headers" + PathHandlingKey = "/path-handling" // GatewayClassUnmanagedAnnotationSuffix is an annotation used on a Gateway resource to // indicate that the GatewayClass should be reconciled according to unmanaged @@ -253,6 +259,70 @@ func ExtractHostAliases(anns map[string]string) ([]string, bool) { return strings.Split(val, ","), true } +// ExtractConnectTimeout extracts the connection timeout annotation value. +func ExtractConnectTimeout(anns map[string]string) (string, bool) { + val, exists := anns[AnnotationPrefix+ConnectTimeoutKey] + if !exists { + return "", false + } + return val, true +} + +// ExtractWriteTimeout extracts the write timeout annotation value. +func ExtractWriteTimeout(anns map[string]string) (string, bool) { + val, exists := anns[AnnotationPrefix+WriteTimeoutKey] + if !exists { + return "", false + } + return val, true +} + +// ExtractReadTimeout extracts the read timeout annotation value. +func ExtractReadTimeout(anns map[string]string) (string, bool) { + val, exists := anns[AnnotationPrefix+ReadTimeoutKey] + if !exists { + return "", false + } + return val, true +} + +// ExtractRetries extracts the retries annotation value. +func ExtractRetries(anns map[string]string) (string, bool) { + val, exists := anns[AnnotationPrefix+RetriesKey] + if !exists { + return "", false + } + return val, true +} + +// ExtractHeaders extracts the parsed headers annotations values. It returns a map of header names to slices of values. +func ExtractHeaders(anns map[string]string) (map[string][]string, bool) { + headers := make(map[string][]string) + prefix := AnnotationPrefix + HeadersKey + "/" + for key, val := range anns { + if strings.HasPrefix(key, prefix) { + header := strings.TrimPrefix(key, prefix) + if len(header) == 0 || len(val) == 0 { + continue + } + headers[header] = strings.Split(val, ",") + } + } + if len(headers) == 0 { + return headers, false + } + return headers, true +} + +// ExtractPathHandling extracts the path handling annotation value. +func ExtractPathHandling(anns map[string]string) (string, bool) { + val, exists := anns[AnnotationPrefix+PathHandlingKey] + if !exists { + return "", false + } + return val, true +} + // ExtractUnmanagedGatewayClassMode extracts the value of the unmanaged gateway // mode annotation. func ExtractUnmanagedGatewayClassMode(anns map[string]string) string { diff --git a/internal/annotations/annotations_test.go b/internal/annotations/annotations_test.go index e3c98f7afb..19c250ab57 100644 --- a/internal/annotations/annotations_test.go +++ b/internal/annotations/annotations_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" extensions "k8s.io/api/extensions/v1beta1" netv1 "k8s.io/api/networking/v1" @@ -684,3 +685,245 @@ func TestExtractHostAliases(t *testing.T) { }) } } + +func TestExtractConnectTimeout(t *testing.T) { + type args struct { + anns map[string]string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + want: "", + }, + { + name: "non-empty", + args: args{ + anns: map[string]string{ + "konghq.com/connect-timeout": "3000", + }, + }, + want: "3000", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ExtractConnectTimeout(tt.args.anns) + if tt.want == "" { + assert.False(t, ok) + } else { + assert.True(t, ok) + require.Equal(t, tt.want, got) + } + }) + } +} + +func TestExtractWriteTimeout(t *testing.T) { + type args struct { + anns map[string]string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + want: "", + }, + { + name: "non-empty", + args: args{ + anns: map[string]string{ + "konghq.com/write-timeout": "3000", + }, + }, + want: "3000", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ExtractWriteTimeout(tt.args.anns) + if tt.want == "" { + assert.False(t, ok) + } else { + assert.True(t, ok) + require.Equal(t, tt.want, got) + } + }) + } +} + +func TestExtractReadTimeout(t *testing.T) { + type args struct { + anns map[string]string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + want: "", + }, + { + name: "non-empty", + args: args{ + anns: map[string]string{ + "konghq.com/read-timeout": "3000", + }, + }, + want: "3000", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ExtractReadTimeout(tt.args.anns) + if tt.want == "" { + assert.False(t, ok) + } else { + assert.True(t, ok) + require.Equal(t, tt.want, got) + } + }) + } +} + +func TestExtractRetries(t *testing.T) { + type args struct { + anns map[string]string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + want: "", + }, + { + name: "non-empty", + args: args{ + anns: map[string]string{ + "konghq.com/retries": "3000", + }, + }, + want: "3000", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ExtractRetries(tt.args.anns) + if tt.want == "" { + assert.False(t, ok) + } else { + assert.True(t, ok) + } + if got != tt.want { + t.Errorf("ExtractRetries() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractHeaders(t *testing.T) { + type args struct { + anns map[string]string + } + tests := []struct { + name string + args args + want map[string][]string + }{ + { + name: "empty", + want: map[string][]string{}, + }, + { + name: "non-empty", + args: args{ + anns: map[string]string{ + "konghq.com/headers/foo": "foo", + }, + }, + want: map[string][]string{"foo": {"foo"}}, + }, + { + name: "no separator", + args: args{ + anns: map[string]string{ + "konghq.com/headersfoo": "foo", + }, + }, + want: map[string][]string{}, + }, + { + name: "no header name", + args: args{ + anns: map[string]string{ + "konghq.com/headers/": "foo", + }, + }, + want: map[string][]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ExtractHeaders(tt.args.anns) + if len(tt.want) == 0 { + assert.False(t, ok) + } else { + assert.True(t, ok) + } + for key, val := range tt.want { + actual, ok := got[key] + assert.True(t, ok) + assert.Equal(t, val, actual) + } + }) + } +} + +func TestExtractPathHandling(t *testing.T) { + type args struct { + anns map[string]string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + want: "", + }, + { + name: "non-empty", + args: args{ + anns: map[string]string{ + "konghq.com/path-handling": "v1", + }, + }, + want: "v1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ExtractPathHandling(tt.args.anns) + if tt.want == "" { + assert.False(t, ok) + } else { + assert.True(t, ok) + } + if got != tt.want { + t.Errorf("ExtractPathHandling() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/dataplane/kongstate/route.go b/internal/dataplane/kongstate/route.go index 373a9a834e..6fbe6db050 100644 --- a/internal/dataplane/kongstate/route.go +++ b/internal/dataplane/kongstate/route.go @@ -24,7 +24,8 @@ type Route struct { } var ( - validMethods = regexp.MustCompile(`\A[A-Z]+$`) + validMethods = regexp.MustCompile(`\A[A-Z]+$`) + validPathHandling = regexp.MustCompile(`v\d`) // hostnames are complicated. shamelessly cribbed from https://stackoverflow.com/a/18494710 // TODO if the Kong core adds support for wildcard SNI route match criteria, this should change. @@ -228,6 +229,8 @@ func (r *Route) overrideByAnnotation(log logrus.FieldLogger) { r.overrideRequestBuffering(log, r.Ingress.Annotations) r.overrideResponseBuffering(log, r.Ingress.Annotations) r.overrideHosts(log, r.Ingress.Annotations) + r.overrideHeaders(r.Ingress.Annotations) + r.overridePathHandling(log, r.Ingress.Annotations) } // override sets Route fields by KongIngress first, then by annotation. @@ -405,3 +408,25 @@ func (r *Route) overrideHosts(log logrus.FieldLogger, anns map[string]string) { r.Hosts = hosts } + +func (r *Route) overrideHeaders(anns map[string]string) { + headers, exists := annotations.ExtractHeaders(anns) + if !exists { + return + } + r.Headers = headers +} + +func (r *Route) overridePathHandling(log logrus.FieldLogger, anns map[string]string) { + val, ok := annotations.ExtractPathHandling(anns) + if !ok { + return + } + + if !validPathHandling.MatchString(val) { + log.WithField("kongroute", r.Name).Errorf("invalid path_handling value: %s", val) + return + } + + r.PathHandling = kong.String(val) +} diff --git a/internal/dataplane/kongstate/route_test.go b/internal/dataplane/kongstate/route_test.go index 5d6a3d43ee..5200259765 100644 --- a/internal/dataplane/kongstate/route_test.go +++ b/internal/dataplane/kongstate/route_test.go @@ -1055,3 +1055,133 @@ func TestOverrideHosts(t *testing.T) { }) } } + +func TestOverrideHeaders(t *testing.T) { + type args struct { + route Route + anns map[string]string + } + tests := []struct { + name string + args args + want Route + }{ + { + name: "basic empty route", + }, + { + name: "single header single value", + args: args{ + anns: map[string]string{ + "konghq.com/headers/x-example": "example", + }, + }, + want: Route{ + Route: kong.Route{ + Headers: map[string][]string{"x-example": {"example"}}, + }, + }, + }, + { + name: "single header multi value", + args: args{ + anns: map[string]string{ + "konghq.com/headers/x-example": "foo,bar", + }, + }, + want: Route{ + Route: kong.Route{ + Headers: map[string][]string{"x-example": {"foo", "bar"}}, + }, + }, + }, + { + name: "multi header single value", + args: args{ + anns: map[string]string{ + "konghq.com/headers/x-foo": "example", + "konghq.com/headers/x-bar": "example", + }, + }, + want: Route{ + Route: kong.Route{ + Headers: map[string][]string{ + "x-foo": {"example"}, + "x-bar": {"example"}, + }, + }, + }, + }, + { + name: "multi header multi value", + args: args{ + anns: map[string]string{ + "konghq.com/headers/x-foo": "foo,bar", + "konghq.com/headers/x-bar": "bar,baz", + }, + }, + want: Route{ + Route: kong.Route{ + Headers: map[string][]string{ + "x-foo": {"foo", "bar"}, + "x-bar": {"bar", "baz"}, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.args.route.overrideHeaders(tt.args.anns) + if !reflect.DeepEqual(tt.args.route, tt.want) { + t.Errorf("overrideHeaders() got = %v, want %v", tt.args.route, tt.want) + } + }) + } +} + +func TestOverridePathHandling(t *testing.T) { + type args struct { + route Route + anns map[string]string + } + tests := []struct { + name string + args args + want Route + }{ + {name: "basic empty route"}, + { + name: "expected value", + args: args{ + anns: map[string]string{ + "konghq.com/path-handling": "v1", + }, + }, + want: Route{ + Route: kong.Route{ + PathHandling: kong.String("v1"), + }, + }, + }, + { + name: "invalid value", + args: args{ + anns: map[string]string{ + "konghq.com/path-handling": "vA", + }, + }, + want: Route{ + Route: kong.Route{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.args.route.overridePathHandling(logrus.New(), tt.args.anns) + if !reflect.DeepEqual(tt.args.route, tt.want) { + t.Errorf("overridePathHandling() got = %v, want %v", tt.args.route, tt.want) + } + }) + } +} diff --git a/internal/dataplane/kongstate/service.go b/internal/dataplane/kongstate/service.go index 8cdc003b07..4b121e6e98 100644 --- a/internal/dataplane/kongstate/service.go +++ b/internal/dataplane/kongstate/service.go @@ -1,6 +1,7 @@ package kongstate import ( + "strconv" "strings" "github.com/kong/go-kong/kong" @@ -106,6 +107,66 @@ func (s *Service) overrideProtocol(anns map[string]string) { s.Protocol = kong.String(protocol) } +func (s *Service) overrideConnectTimeout(anns map[string]string) { + if s == nil { + return + } + timeout, exists := annotations.ExtractConnectTimeout(anns) + if !exists { + return + } + val, err := strconv.Atoi(timeout) + if err != nil { + return + } + s.ConnectTimeout = kong.Int(val) +} + +func (s *Service) overrideWriteTimeout(anns map[string]string) { + if s == nil { + return + } + timeout, exists := annotations.ExtractWriteTimeout(anns) + if !exists { + return + } + val, err := strconv.Atoi(timeout) + if err != nil { + return + } + s.WriteTimeout = kong.Int(val) +} + +func (s *Service) overrideReadTimeout(anns map[string]string) { + if s == nil { + return + } + timeout, exists := annotations.ExtractReadTimeout(anns) + if !exists { + return + } + val, err := strconv.Atoi(timeout) + if err != nil { + return + } + s.ReadTimeout = kong.Int(val) +} + +func (s *Service) overrideRetries(anns map[string]string) { + if s == nil { + return + } + retries, exists := annotations.ExtractRetries(anns) + if !exists { + return + } + val, err := strconv.Atoi(retries) + if err != nil { + return + } + s.Retries = kong.Int(val) +} + // overrideByAnnotation modifies the Kong service based on annotations // on the Kubernetes service. func (s *Service) overrideByAnnotation(anns map[string]string) { @@ -114,6 +175,10 @@ func (s *Service) overrideByAnnotation(anns map[string]string) { } s.overrideProtocol(anns) s.overridePath(anns) + s.overrideConnectTimeout(anns) + s.overrideWriteTimeout(anns) + s.overrideReadTimeout(anns) + s.overrideRetries(anns) } // override sets Service fields by KongIngress first, then by k8s Service's annotations. diff --git a/internal/dataplane/kongstate/service_test.go b/internal/dataplane/kongstate/service_test.go index 72b9a13c53..23f5ed59b8 100644 --- a/internal/dataplane/kongstate/service_test.go +++ b/internal/dataplane/kongstate/service_test.go @@ -444,3 +444,247 @@ func TestOverrideServicePath(t *testing.T) { }) } } + +func TestOverrideConnectTimeout(t *testing.T) { + type args struct { + service Service + anns map[string]string + } + tests := []struct { + name string + args args + want Service + }{ + { + name: "set to valid value", + args: args{ + anns: map[string]string{ + "konghq.com/connect-timeout": "3000", + }, + }, + want: Service{ + Service: kong.Service{ + ConnectTimeout: kong.Int(3000), + }, + }, + }, + { + name: "value cannot parse to int", + args: args{ + anns: map[string]string{ + "konghq.com/connect-timeout": "burranyi yedigei", + }, + }, + want: Service{}, + }, + { + name: "overrides any other value", + args: args{ + service: Service{ + Service: kong.Service{ + ConnectTimeout: kong.Int(2000), + }, + }, + anns: map[string]string{ + "konghq.com/connect-timeout": "3000", + }, + }, + want: Service{ + Service: kong.Service{ + ConnectTimeout: kong.Int(3000), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.args.service.overrideConnectTimeout(tt.args.anns) + if !reflect.DeepEqual(tt.args.service, tt.want) { + t.Errorf("overrideConnectTimeout() got = %v, want %v", tt.args.service, tt.want) + } + }) + } +} + +func TestOverrideWriteTimeout(t *testing.T) { + type args struct { + service Service + anns map[string]string + } + tests := []struct { + name string + args args + want Service + }{ + { + name: "set to valid value", + args: args{ + anns: map[string]string{ + "konghq.com/write-timeout": "3000", + }, + }, + want: Service{ + Service: kong.Service{ + WriteTimeout: kong.Int(3000), + }, + }, + }, + { + name: "value cannot parse to int", + args: args{ + anns: map[string]string{ + "konghq.com/write-timeout": "burranyi yedigei", + }, + }, + want: Service{}, + }, + { + name: "overrides any other value", + args: args{ + service: Service{ + Service: kong.Service{ + WriteTimeout: kong.Int(2000), + }, + }, + anns: map[string]string{ + "konghq.com/write-timeout": "3000", + }, + }, + want: Service{ + Service: kong.Service{ + WriteTimeout: kong.Int(3000), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.args.service.overrideWriteTimeout(tt.args.anns) + if !reflect.DeepEqual(tt.args.service, tt.want) { + t.Errorf("overrideWriteTimeout() got = %v, want %v", tt.args.service, tt.want) + } + }) + } +} + +func TestOverrideReadTimeout(t *testing.T) { + type args struct { + service Service + anns map[string]string + } + tests := []struct { + name string + args args + want Service + }{ + { + name: "set to valid value", + args: args{ + anns: map[string]string{ + "konghq.com/read-timeout": "3000", + }, + }, + want: Service{ + Service: kong.Service{ + ReadTimeout: kong.Int(3000), + }, + }, + }, + { + name: "value cannot parse to int", + args: args{ + anns: map[string]string{ + "konghq.com/read-timeout": "burranyi yedigei", + }, + }, + want: Service{}, + }, + { + name: "overrides any other value", + args: args{ + service: Service{ + Service: kong.Service{ + ReadTimeout: kong.Int(2000), + }, + }, + anns: map[string]string{ + "konghq.com/read-timeout": "3000", + }, + }, + want: Service{ + Service: kong.Service{ + ReadTimeout: kong.Int(3000), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.args.service.overrideReadTimeout(tt.args.anns) + if !reflect.DeepEqual(tt.args.service, tt.want) { + t.Errorf("overrideReadTimeout() got = %v, want %v", tt.args.service, tt.want) + } + }) + } +} + +func TestOverrideRetries(t *testing.T) { + type args struct { + service Service + anns map[string]string + } + tests := []struct { + name string + args args + want Service + }{ + { + name: "set to valid value", + args: args{ + anns: map[string]string{ + "konghq.com/retries": "3000", + }, + }, + want: Service{ + Service: kong.Service{ + Retries: kong.Int(3000), + }, + }, + }, + { + name: "value cannot parse to int", + args: args{ + anns: map[string]string{ + "konghq.com/retries": "burranyi yedigei", + }, + }, + want: Service{}, + }, + { + name: "overrides any other value", + args: args{ + service: Service{ + Service: kong.Service{ + Retries: kong.Int(2000), + }, + }, + anns: map[string]string{ + "konghq.com/retries": "3000", + }, + }, + want: Service{ + Service: kong.Service{ + Retries: kong.Int(3000), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.args.service.overrideRetries(tt.args.anns) + if !reflect.DeepEqual(tt.args.service, tt.want) { + t.Errorf("overrideRetries() got = %v, want %v", tt.args.service, tt.want) + } + }) + } +}