diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e2df76e3..a9abb42b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - (Bugfix) (Platform) Installer move to OCI - (Bugfix) (Platform) Fix Monitoring RBAC - (Feature) (Platform) Do not require LM during install commands +- (Feature) (Platform) ArangoRoute Redirect ## [1.3.1](https://github.com/arangodb/kube-arangodb/tree/1.3.1) (2025-10-07) - (Documentation) Add ArangoPlatformStorage Docs & Examples diff --git a/docs/api/ArangoRoute.V1Beta1.md b/docs/api/ArangoRoute.V1Beta1.md index 0402ede37..d272fe459 100644 --- a/docs/api/ArangoRoute.V1Beta1.md +++ b/docs/api/ArangoRoute.V1Beta1.md @@ -73,7 +73,7 @@ Port defines Port or Port Name used as destination ### .spec.destination.path -Type: `string` [\[ref\]](https://github.com/arangodb/kube-arangodb/blob/1.3.1/pkg/apis/networking/v1beta1/route_spec_destination.go#L52) +Type: `string` [\[ref\]](https://github.com/arangodb/kube-arangodb/blob/1.3.1/pkg/apis/networking/v1beta1/route_spec_destination.go#L57) Path defines service path used for overrides @@ -81,7 +81,7 @@ Path defines service path used for overrides ### .spec.destination.protocol -Type: `string` [\[ref\]](https://github.com/arangodb/kube-arangodb/blob/1.3.1/pkg/apis/networking/v1beta1/route_spec_destination.go#L46) +Type: `string` [\[ref\]](https://github.com/arangodb/kube-arangodb/blob/1.3.1/pkg/apis/networking/v1beta1/route_spec_destination.go#L51) Protocol defines http protocol used for the route @@ -91,9 +91,19 @@ Possible Values: *** +### .spec.destination.redirect.code + +Type: `integer` [\[ref\]](https://github.com/arangodb/kube-arangodb/blob/1.3.1/pkg/apis/networking/v1beta1/route_spec_destination_redirect.go#L33) + +Code the redirection response status code + +Default Value: `307` + +*** + ### .spec.destination.schema -Type: `string` [\[ref\]](https://github.com/arangodb/kube-arangodb/blob/1.3.1/pkg/apis/networking/v1beta1/route_spec_destination.go#L41) +Type: `string` [\[ref\]](https://github.com/arangodb/kube-arangodb/blob/1.3.1/pkg/apis/networking/v1beta1/route_spec_destination.go#L45) Schema defines HTTP/S schema used for connection @@ -133,7 +143,7 @@ Port defines Port or Port Name used as destination ### .spec.destination.timeout -Type: `string` [\[ref\]](https://github.com/arangodb/kube-arangodb/blob/1.3.1/pkg/apis/networking/v1beta1/route_spec_destination.go#L60) +Type: `string` [\[ref\]](https://github.com/arangodb/kube-arangodb/blob/1.3.1/pkg/apis/networking/v1beta1/route_spec_destination.go#L65) Timeout specify the upstream request timeout diff --git a/pkg/apis/networking/v1beta1/route_spec_destination.go b/pkg/apis/networking/v1beta1/route_spec_destination.go index 8ab1412a1..bded8e539 100644 --- a/pkg/apis/networking/v1beta1/route_spec_destination.go +++ b/pkg/apis/networking/v1beta1/route_spec_destination.go @@ -35,12 +35,17 @@ type ArangoRouteSpecDestination struct { // Endpoints defines service upstream reference - which is used to find endpoints Endpoints *ArangoRouteSpecDestinationEndpoints `json:"endpoints,omitempty"` + // Redirect defines redirect instruction + Redirect *ArangoRouteSpecDestinationRedirect `json:"redirect,omitempty"` + // Schema defines HTTP/S schema used for connection + // +doc/default: http // +doc/enum: http|HTTP Connection // +doc/enum: https|HTTPS Connection (HTTP with TLS) Schema *ArangoRouteSpecDestinationSchema `json:"schema,omitempty"` // Protocol defines http protocol used for the route + // +doc/default: http1 // +doc/enum: http1|HTTP 1.1 Protocol // +doc/enum: http2|HTTP 2 Protocol Protocol *ArangoRouteDestinationProtocol `json:"protocol,omitempty"` @@ -68,6 +73,14 @@ func (a *ArangoRouteSpecDestination) GetService() *ArangoRouteSpecDestinationSer return a.Service } +func (a *ArangoRouteSpecDestination) GetRedirect() *ArangoRouteSpecDestinationRedirect { + if a == nil || a.Redirect == nil { + return nil + } + + return a.Redirect +} + func (a *ArangoRouteSpecDestination) GetEndpoints() *ArangoRouteSpecDestinationEndpoints { if a == nil || a.Endpoints == nil { return nil @@ -132,9 +145,10 @@ func (a *ArangoRouteSpecDestination) Validate() error { } if err := shared.WithErrors( - shared.ValidateExclusiveFields(a, 1, "Service", "Endpoints"), + shared.ValidateExclusiveFields(a, 1, "Service", "Endpoints", "Redirect"), shared.ValidateOptionalInterfacePath("service", a.Service), shared.ValidateOptionalInterfacePath("endpoints", a.Endpoints), + shared.ValidateOptionalInterfacePath("redirect", a.Redirect), shared.ValidateOptionalInterfacePath("schema", a.Schema), shared.ValidateOptionalInterfacePath("protocol", a.Protocol), shared.ValidateOptionalInterfacePath("tls", a.TLS), diff --git a/pkg/apis/networking/v1beta1/route_spec_destination_redirect.go b/pkg/apis/networking/v1beta1/route_spec_destination_redirect.go new file mode 100644 index 000000000..803700bde --- /dev/null +++ b/pkg/apis/networking/v1beta1/route_spec_destination_redirect.go @@ -0,0 +1,71 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package v1beta1 + +import ( + goHttp "net/http" + + shared "github.com/arangodb/kube-arangodb/pkg/apis/shared" + "github.com/arangodb/kube-arangodb/pkg/util/errors" +) + +type ArangoRouteSpecDestinationRedirect struct { + // Code the redirection response status code + // +doc/default: 307 + Code *int `json:"code,omitempty"` +} + +func (a *ArangoRouteSpecDestinationRedirect) GetCode() int { + if a == nil || a.Code == nil { + return goHttp.StatusTemporaryRedirect + } + return *a.Code +} + +func (a *ArangoRouteSpecDestinationRedirect) Validate() error { + if a == nil { + a = &ArangoRouteSpecDestinationRedirect{} + } + + if err := shared.WithErrors( + shared.ValidateOptionalPath("code", a.Code, func(i int) error { + if i == goHttp.StatusTemporaryRedirect || i == goHttp.StatusMovedPermanently { + return nil + } + + return errors.Errorf("Invalid code. Got %d, allowed 301 & 307", i) + }), + ); err != nil { + return err + } + + return nil +} + +func (a *ArangoRouteSpecDestinationRedirect) AsStatus() ArangoRouteStatusTargetRedirect { + if a == nil { + return ArangoRouteStatusTargetRedirect{} + } + + return ArangoRouteStatusTargetRedirect{ + Code: a.GetCode(), + } +} diff --git a/pkg/apis/networking/v1beta1/route_status_target.go b/pkg/apis/networking/v1beta1/route_status_target.go index 31d4207f0..6cf5fb7bb 100644 --- a/pkg/apis/networking/v1beta1/route_status_target.go +++ b/pkg/apis/networking/v1beta1/route_status_target.go @@ -55,6 +55,9 @@ type ArangoRouteStatusTarget struct { // Timeout specify the upstream request timeout Timeout meta.Duration `json:"timeout,omitempty"` + + // Redirect defines the route status + Redirect ArangoRouteStatusTargetRedirect `json:"redirect,omitempty"` } func (a *ArangoRouteStatusTarget) RenderURLs() []string { @@ -81,5 +84,5 @@ func (a *ArangoRouteStatusTarget) Hash() string { if a == nil { return "" } - return util.SHA256FromStringArray(a.Destinations.Hash(), a.Type.Hash(), a.TLS.Hash(), a.Protocol.String(), a.Path, a.Authentication.Hash(), a.Options.Hash(), a.Timeout.String(), a.Route.Hash()) + return util.SHA256FromNonEmptyStringArray(a.Destinations.Hash(), a.Type.Hash(), a.TLS.Hash(), a.Protocol.String(), a.Path, a.Authentication.Hash(), a.Options.Hash(), a.Timeout.String(), a.Route.Hash(), a.Redirect.Hash()) } diff --git a/pkg/apis/networking/v1beta1/route_status_target_redirect.go b/pkg/apis/networking/v1beta1/route_status_target_redirect.go new file mode 100644 index 000000000..b0ba0d32c --- /dev/null +++ b/pkg/apis/networking/v1beta1/route_status_target_redirect.go @@ -0,0 +1,35 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package v1beta1 + +import ( + "fmt" + + "github.com/arangodb/kube-arangodb/pkg/util" +) + +type ArangoRouteStatusTargetRedirect struct { + Code int `json:"code,omitempty"` +} + +func (a ArangoRouteStatusTargetRedirect) Hash() string { + return util.SHA256FromStringArray(fmt.Sprintf("%d", a.Code)) +} diff --git a/pkg/apis/networking/v1beta1/route_status_target_type.go b/pkg/apis/networking/v1beta1/route_status_target_type.go index 587375ff8..916aa8f09 100644 --- a/pkg/apis/networking/v1beta1/route_status_target_type.go +++ b/pkg/apis/networking/v1beta1/route_status_target_type.go @@ -31,4 +31,5 @@ func (a ArangoRouteStatusTargetType) Hash() string { const ( ArangoRouteStatusTargetServiceType ArangoRouteStatusTargetType = "service" ArangoRouteStatusTargetEndpointsType ArangoRouteStatusTargetType = "endpoints" + ArangoRouteStatusTargetRedirectType ArangoRouteStatusTargetType = "redirect" ) diff --git a/pkg/apis/networking/v1beta1/zz_generated.deepcopy.go b/pkg/apis/networking/v1beta1/zz_generated.deepcopy.go index 1b2493f93..b1d116dae 100644 --- a/pkg/apis/networking/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/networking/v1beta1/zz_generated.deepcopy.go @@ -143,6 +143,11 @@ func (in *ArangoRouteSpecDestination) DeepCopyInto(out *ArangoRouteSpecDestinati *out = new(ArangoRouteSpecDestinationEndpoints) (*in).DeepCopyInto(*out) } + if in.Redirect != nil { + in, out := &in.Redirect, &out.Redirect + *out = new(ArangoRouteSpecDestinationRedirect) + (*in).DeepCopyInto(*out) + } if in.Schema != nil { in, out := &in.Schema, &out.Schema *out = new(ArangoRouteSpecDestinationSchema) @@ -238,6 +243,27 @@ func (in *ArangoRouteSpecDestinationEndpoints) DeepCopy() *ArangoRouteSpecDestin return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ArangoRouteSpecDestinationRedirect) DeepCopyInto(out *ArangoRouteSpecDestinationRedirect) { + *out = *in + if in.Code != nil { + in, out := &in.Code, &out.Code + *out = new(int) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ArangoRouteSpecDestinationRedirect. +func (in *ArangoRouteSpecDestinationRedirect) DeepCopy() *ArangoRouteSpecDestinationRedirect { + if in == nil { + return nil + } + out := new(ArangoRouteSpecDestinationRedirect) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ArangoRouteSpecDestinationService) DeepCopyInto(out *ArangoRouteSpecDestinationService) { *out = *in @@ -426,6 +452,7 @@ func (in *ArangoRouteStatusTarget) DeepCopyInto(out *ArangoRouteStatusTarget) { } out.Route = in.Route out.Timeout = in.Timeout + out.Redirect = in.Redirect return } @@ -557,6 +584,22 @@ func (in ArangoRouteStatusTargetOptionsUpgrade) DeepCopy() ArangoRouteStatusTarg return *out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ArangoRouteStatusTargetRedirect) DeepCopyInto(out *ArangoRouteStatusTargetRedirect) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ArangoRouteStatusTargetRedirect. +func (in *ArangoRouteStatusTargetRedirect) DeepCopy() *ArangoRouteStatusTargetRedirect { + if in == nil { + return nil + } + out := new(ArangoRouteStatusTargetRedirect) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ArangoRouteStatusTargetRoute) DeepCopyInto(out *ArangoRouteStatusTargetRoute) { *out = *in diff --git a/pkg/apis/shared/validate.go b/pkg/apis/shared/validate.go index 420e76a2a..548169165 100644 --- a/pkg/apis/shared/validate.go +++ b/pkg/apis/shared/validate.go @@ -205,6 +205,13 @@ func ValidatePath[T any](path string, in T, validator func(T) error) error { return PrefixResourceErrors(path, validator(in)) } +// ValidateMultiPath Validates object +func ValidateMultiPath[T any](path string, in T, validators ...func(T) error) error { + return PrefixResourceErrors(path, util.FormatList(validators, func(a func(T) error) error { + return a(in) + })...) +} + // ValidateRequiredPath Validates object and required not nil value func ValidateRequiredPath[T any](path string, in *T, validator func(T) error) error { return PrefixResourceErrors(path, ValidateRequired(in, validator)) diff --git a/pkg/crd/crds/networking-route.schema.generated.yaml b/pkg/crd/crds/networking-route.schema.generated.yaml index c5ed2539b..09d1ed325 100644 --- a/pkg/crd/crds/networking-route.schema.generated.yaml +++ b/pkg/crd/crds/networking-route.schema.generated.yaml @@ -174,6 +174,14 @@ v1beta1: - http1 - http2 type: string + redirect: + description: Redirect defines redirect instruction + properties: + code: + description: Code the redirection response status code + format: int32 + type: integer + type: object schema: description: Schema defines HTTP/S schema used for connection enum: diff --git a/pkg/deployment/resources/config_map_gateway.go b/pkg/deployment/resources/config_map_gateway.go index 4b3840d36..b4ecd5506 100644 --- a/pkg/deployment/resources/config_map_gateway.go +++ b/pkg/deployment/resources/config_map_gateway.go @@ -347,50 +347,76 @@ func (r *Resources) renderGatewayConfig(cachedStatus inspectorInterface.Inspecto } if target := at.Status.Target; target != nil { + var dest gateway.ConfigDestination + if target.Route.Path == "" { log.Warn("ArangoRoute Route Path not defined") return nil } - var dest gateway.ConfigDestination - if destinations := target.Destinations; len(destinations) > 0 { - for _, destination := range destinations { - var t gateway.ConfigDestinationTarget - t.Host = destination.Host - t.Port = destination.Port + switch target.Type { + case networkingApi.ArangoRouteStatusTargetRedirectType: + dest.Redirect = &gateway.ConfigDestinationRedirect{ + Code: target.Redirect.Code, + } + dest.Path = util.NewType(target.Path) + dest.Match = util.NewType(gateway.ConfigMatchPath) + dest.Type = util.NewType(gateway.ConfigDestinationTypeRedirect) + dest.ResponseHeaders = map[string]string{ + utilConstants.EnvoyRouteHeader: at.GetName(), + } + dest.AuthExtension = &gateway.ConfigAuthZExtension{ + AuthZExtension: map[string]string{ + pbImplEnvoyAuthV3Shared.AuthConfigAuthRequiredKey: pbImplEnvoyAuthV3Shared.AuthConfigKeywordFalse, + pbImplEnvoyAuthV3Shared.AuthConfigAuthPassModeKey: string(networkingApi.ArangoRouteSpecAuthenticationPassModeRemove), + }, + } + case networkingApi.ArangoRouteStatusTargetEndpointsType, networkingApi.ArangoRouteStatusTargetServiceType: + if destinations := target.Destinations; len(destinations) > 0 { + for _, destination := range destinations { + var t gateway.ConfigDestinationTarget + + t.Host = destination.Host + t.Port = destination.Port - dest.Targets = append(dest.Targets, t) + dest.Targets = append(dest.Targets, t) + } } - } - if tls := target.TLS; tls != nil { - dest.Type = util.NewType(gateway.ConfigDestinationTypeHTTPS) - dest.TLS.Insecure = util.NewType(tls.IsInsecure()) - } - switch target.Protocol { - case networkingApi.ArangoRouteDestinationProtocolHTTP1: - dest.Protocol = util.NewType(gateway.ConfigDestinationProtocolHTTP1) - case networkingApi.ArangoRouteDestinationProtocolHTTP2: - dest.Protocol = util.NewType(gateway.ConfigDestinationProtocolHTTP2) - } - if opts := target.Options; opts != nil { - for _, upgrade := range opts.Upgrade { - dest.UpgradeConfigs = append(dest.UpgradeConfigs, gateway.ConfigDestinationUpgrade{ - Type: string(upgrade.Type), - Enabled: util.NewType(util.WithDefault(upgrade.Enabled)), - }) + dest.Match = util.NewType(gateway.ConfigMatchPrefix) + dest.Type = util.NewType(gateway.ConfigDestinationTypeHTTP) + if tls := target.TLS; tls != nil { + dest.Type = util.NewType(gateway.ConfigDestinationTypeHTTPS) + dest.TLS.Insecure = util.NewType(tls.IsInsecure()) } + switch target.Protocol { + case networkingApi.ArangoRouteDestinationProtocolHTTP1: + dest.Protocol = util.NewType(gateway.ConfigDestinationProtocolHTTP1) + case networkingApi.ArangoRouteDestinationProtocolHTTP2: + dest.Protocol = util.NewType(gateway.ConfigDestinationProtocolHTTP2) + } + if opts := target.Options; opts != nil { + for _, upgrade := range opts.Upgrade { + dest.UpgradeConfigs = append(dest.UpgradeConfigs, gateway.ConfigDestinationUpgrade{ + Type: string(upgrade.Type), + Enabled: util.NewType(util.WithDefault(upgrade.Enabled)), + }) + } + } + dest.Path = util.NewType(target.Path) + dest.Timeout = target.Timeout.DeepCopy() + dest.AuthExtension = &gateway.ConfigAuthZExtension{ + AuthZExtension: map[string]string{ + pbImplEnvoyAuthV3Shared.AuthConfigAuthRequiredKey: util.BoolSwitch[string](target.Authentication.Type.Get() == networkingApi.ArangoRouteSpecAuthenticationTypeRequired, pbImplEnvoyAuthV3Shared.AuthConfigKeywordTrue, pbImplEnvoyAuthV3Shared.AuthConfigKeywordFalse), + pbImplEnvoyAuthV3Shared.AuthConfigAuthPassModeKey: string(target.Authentication.PassMode), + }, + } + dest.ResponseHeaders = map[string]string{ + utilConstants.EnvoyRouteHeader: at.GetName(), + } + default: + return errors.Errorf("Unknown route destination type %s", target.Type) } - dest.Path = util.NewType(target.Path) - dest.Timeout = target.Timeout.DeepCopy() - dest.AuthExtension = &gateway.ConfigAuthZExtension{ - AuthZExtension: map[string]string{ - pbImplEnvoyAuthV3Shared.AuthConfigAuthRequiredKey: util.BoolSwitch[string](target.Authentication.Type.Get() == networkingApi.ArangoRouteSpecAuthenticationTypeRequired, pbImplEnvoyAuthV3Shared.AuthConfigKeywordTrue, pbImplEnvoyAuthV3Shared.AuthConfigKeywordFalse), - pbImplEnvoyAuthV3Shared.AuthConfigAuthPassModeKey: string(target.Authentication.PassMode), - }, - } - dest.ResponseHeaders = map[string]string{ - utilConstants.EnvoyRouteHeader: at.GetName(), - } + cfg.Destinations[target.Route.Path] = dest routes[at.GetName()] = &pbInventoryV1.InventoryNetworkingRoute{ diff --git a/pkg/deployment/resources/gateway/gateway_config_destination.go b/pkg/deployment/resources/gateway/gateway_config_destination.go index ffc39da61..1d64ce123 100644 --- a/pkg/deployment/resources/gateway/gateway_config_destination.go +++ b/pkg/deployment/resources/gateway/gateway_config_destination.go @@ -21,6 +21,7 @@ package gateway import ( + goHttp "net/http" "time" pbEnvoyClusterV3 "github.com/envoyproxy/go-control-plane/envoy/config/cluster/v3" @@ -45,7 +46,7 @@ func (c ConfigDestinations) Validate() error { return shared.WithErrors( shared.ValidateMap(c, func(k string, destination ConfigDestination) error { var errs []error - if k == "/" { + if k == "/" && destination.Type.Get() != ConfigDestinationTypeRedirect { errs = append(errs, errors.Errorf("Route for `/` is reserved")) } if err := shared.ValidateAPIPath(k); err != nil { @@ -85,6 +86,8 @@ type ConfigDestination struct { Static ConfigDestinationStaticInterface `json:"static,omitempty"` File ConfigDestinationFileInterface `json:"file,omitempty"` + + Redirect *ConfigDestinationRedirect `json:"redirect,omitempty"` } func (c *ConfigDestination) Validate() error { @@ -100,6 +103,13 @@ func (c *ConfigDestination) Validate() error { shared.PrefixResourceError("pathType", shared.ValidateOptionalInterface(c.Match)), shared.PrefixResourceError("authExtension", c.AuthExtension.Validate()), ) + case ConfigDestinationTypeRedirect: + return shared.WithErrors( + shared.PrefixResourceError("type", c.Type.Validate()), + shared.PrefixResourceError("path", shared.ValidateAPIPath(c.GetPath())), + shared.PrefixResourceError("pathType", shared.ValidateOptionalInterface(c.Match)), + shared.PrefixResourceError("redirect", c.Redirect.Validate()), + ) case ConfigDestinationTypeStatic: return shared.WithErrors( shared.PrefixResourceError("type", c.Type.Validate()), @@ -230,6 +240,36 @@ func (c *ConfigDestination) appendRouteAction(route *pbEnvoyRouteV3.Route, name } return nil } + if c.Type.Get() == ConfigDestinationTypeRedirect { + if c.Redirect == nil { + return errors.Errorf("Redirect response is not defined!") + } + switch c.Redirect.Code { + case goHttp.StatusTemporaryRedirect: + route.Action = &pbEnvoyRouteV3.Route_Redirect{ + Redirect: &pbEnvoyRouteV3.RedirectAction{ + PathRewriteSpecifier: &pbEnvoyRouteV3.RedirectAction_PathRedirect{ + PathRedirect: c.GetPath(), + }, + ResponseCode: pbEnvoyRouteV3.RedirectAction_TEMPORARY_REDIRECT, + StripQuery: false, + }, + } + return nil + case goHttp.StatusMovedPermanently: + route.Action = &pbEnvoyRouteV3.Route_Redirect{ + Redirect: &pbEnvoyRouteV3.RedirectAction{ + PathRewriteSpecifier: &pbEnvoyRouteV3.RedirectAction_PathRedirect{ + PathRedirect: c.GetPath(), + }, + ResponseCode: pbEnvoyRouteV3.RedirectAction_MOVED_PERMANENTLY, + StripQuery: false, + }, + } + return nil + } + return errors.Errorf("Unable to render redirection action") + } route.Action = &pbEnvoyRouteV3.Route_Route{ Route: &pbEnvoyRouteV3.RouteAction{ diff --git a/pkg/deployment/resources/gateway/gateway_config_destination_redirect.go b/pkg/deployment/resources/gateway/gateway_config_destination_redirect.go new file mode 100644 index 000000000..28119d131 --- /dev/null +++ b/pkg/deployment/resources/gateway/gateway_config_destination_redirect.go @@ -0,0 +1,47 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package gateway + +import ( + goHttp "net/http" + + shared "github.com/arangodb/kube-arangodb/pkg/apis/shared" + "github.com/arangodb/kube-arangodb/pkg/util/errors" +) + +type ConfigDestinationRedirect struct { + Code int `json:"code"` +} + +func (c *ConfigDestinationRedirect) Validate() error { + if c == nil { + c = &ConfigDestinationRedirect{} + } + + return shared.WithErrors( + shared.PrefixResourceErrorFunc("code", func() error { + if c.Code == goHttp.StatusTemporaryRedirect || c.Code == goHttp.StatusMovedPermanently { + return nil + } + return errors.Errorf("Invalid code. Got %d, allowed 301 & 307", c.Code) + }), + ) +} diff --git a/pkg/deployment/resources/gateway/gateway_config_destination_type.go b/pkg/deployment/resources/gateway/gateway_config_destination_type.go index 82b64f3a2..441dd701a 100644 --- a/pkg/deployment/resources/gateway/gateway_config_destination_type.go +++ b/pkg/deployment/resources/gateway/gateway_config_destination_type.go @@ -36,6 +36,7 @@ const ( ConfigDestinationTypeHTTPS ConfigDestinationTypeStatic ConfigDestinationTypeFile + ConfigDestinationTypeRedirect ) func (c *ConfigDestinationType) Get() ConfigDestinationType { @@ -44,7 +45,7 @@ func (c *ConfigDestinationType) Get() ConfigDestinationType { } switch v := *c; v { - case ConfigDestinationTypeHTTP, ConfigDestinationTypeHTTPS, ConfigDestinationTypeStatic, ConfigDestinationTypeFile: + case ConfigDestinationTypeHTTP, ConfigDestinationTypeHTTPS, ConfigDestinationTypeStatic, ConfigDestinationTypeFile, ConfigDestinationTypeRedirect: return v default: return ConfigDestinationTypeHTTP @@ -80,7 +81,7 @@ func (c *ConfigDestinationType) RenderUpstreamTransportSocket(protocol *ConfigDe func (c *ConfigDestinationType) Validate() error { switch c.Get() { - case ConfigDestinationTypeHTTP, ConfigDestinationTypeHTTPS, ConfigDestinationTypeStatic, ConfigDestinationTypeFile: + case ConfigDestinationTypeHTTP, ConfigDestinationTypeHTTPS, ConfigDestinationTypeStatic, ConfigDestinationTypeFile, ConfigDestinationTypeRedirect: return nil default: return errors.Errorf("Invalid destination type") diff --git a/pkg/deployment/resources/gateway/gateway_config_test.go b/pkg/deployment/resources/gateway/gateway_config_test.go index e682a6cf8..6ea76e3ff 100644 --- a/pkg/deployment/resources/gateway/gateway_config_test.go +++ b/pkg/deployment/resources/gateway/gateway_config_test.go @@ -22,6 +22,7 @@ package gateway import ( "fmt" + goHttp "net/http" "testing" pbEnvoyBootstrapV3 "github.com/envoyproxy/go-control-plane/envoy/config/bootstrap/v3" @@ -464,4 +465,28 @@ func Test_GatewayConfig(t *testing.T) { }, }) }) + + t.Run("Default", func(t *testing.T) { + renderAndPrintGatewayConfig(t, Config{ + DefaultDestination: ConfigDestination{ + Targets: []ConfigDestinationTarget{ + { + Host: "127.0.0.1", + Port: 12345, + }, + }, + Path: util.NewType("/test/path/"), + Type: util.NewType(ConfigDestinationTypeHTTPS), + }, + Destinations: ConfigDestinations{ + "/_test/": { + Redirect: &ConfigDestinationRedirect{ + Code: goHttp.StatusTemporaryRedirect, + }, + Path: util.NewType("/test/path/"), + Type: util.NewType(ConfigDestinationTypeRedirect), + }, + }, + }) + }) } diff --git a/pkg/handlers/networking/route/handler_destination.go b/pkg/handlers/networking/route/handler_destination.go index 0cd456bc0..b16b3bbcd 100644 --- a/pkg/handlers/networking/route/handler_destination.go +++ b/pkg/handlers/networking/route/handler_destination.go @@ -37,6 +37,9 @@ func (h *handler) HandleArangoDestination(ctx context.Context, item operation.It if endpoints := dest.GetEndpoints(); endpoints != nil { return h.HandleArangoDestinationEndpoints(ctx, item, extension, status, deployment, dest, endpoints) } + if redirect := dest.GetRedirect(); redirect != nil { + return h.HandleArangoDestinationRedirect(ctx, item, extension, status, deployment, dest, redirect) + } } return &operator.Condition{ diff --git a/pkg/handlers/networking/route/handler_destination_redirect.go b/pkg/handlers/networking/route/handler_destination_redirect.go new file mode 100644 index 000000000..beace47a2 --- /dev/null +++ b/pkg/handlers/networking/route/handler_destination_redirect.go @@ -0,0 +1,56 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package route + +import ( + "context" + + api "github.com/arangodb/kube-arangodb/pkg/apis/deployment/v1" + networkingApi "github.com/arangodb/kube-arangodb/pkg/apis/networking/v1beta1" + operator "github.com/arangodb/kube-arangodb/pkg/operatorV2" + "github.com/arangodb/kube-arangodb/pkg/operatorV2/operation" +) + +func (h *handler) HandleArangoDestinationRedirect(ctx context.Context, item operation.Item, extension *networkingApi.ArangoRoute, status *networkingApi.ArangoRouteStatus, deployment *api.ArangoDeployment, dest *networkingApi.ArangoRouteSpecDestination, redirect *networkingApi.ArangoRouteSpecDestinationRedirect) (*operator.Condition, bool, error) { + var target networkingApi.ArangoRouteStatusTarget + + target.Path = dest.GetPath() + target.Type = networkingApi.ArangoRouteStatusTargetRedirectType + target.Redirect = redirect.AsStatus() + target.Route = extension.Spec.Route.AsStatus() + + if status.Target.Hash() == target.Hash() { + return &operator.Condition{ + Status: true, + Reason: "Destination Found", + Message: "Destination Found", + Hash: target.Hash(), + }, false, nil + } + + status.Target = &target + return &operator.Condition{ + Status: true, + Reason: "Destination Found", + Message: "Destination Found", + Hash: target.Hash(), + }, true, nil +} diff --git a/pkg/handlers/networking/route/handler_destination_redirect_test.go b/pkg/handlers/networking/route/handler_destination_redirect_test.go new file mode 100644 index 000000000..6d31aa207 --- /dev/null +++ b/pkg/handlers/networking/route/handler_destination_redirect_test.go @@ -0,0 +1,265 @@ +// +// DISCLAIMER +// +// Copyright 2025 ArangoDB GmbH, Cologne, Germany +// +// 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. +// +// Copyright holder is ArangoDB GmbH, Cologne, Germany +// + +package route + +import ( + goHttp "net/http" + "testing" + + "github.com/stretchr/testify/require" + + api "github.com/arangodb/kube-arangodb/pkg/apis/deployment/v1" + networkingApi "github.com/arangodb/kube-arangodb/pkg/apis/networking/v1beta1" + "github.com/arangodb/kube-arangodb/pkg/operatorV2/operation" + "github.com/arangodb/kube-arangodb/pkg/util" + "github.com/arangodb/kube-arangodb/pkg/util/tests" +) + +func Test_Handler_Redirect_Valid(t *testing.T) { + // Setup + handler := newFakeHandler() + + // Arrange + extension := tests.NewMetaObject[*networkingApi.ArangoRoute](t, tests.FakeNamespace, "test", + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Deployment = util.NewType("deployment") + }, + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Destination = &networkingApi.ArangoRouteSpecDestination{ + Redirect: &networkingApi.ArangoRouteSpecDestinationRedirect{}, + Path: util.NewType("/ui/"), + } + }) + deployment := tests.NewMetaObject[*api.ArangoDeployment](t, tests.FakeNamespace, "deployment") + + refresh := tests.CreateObjects(t, handler.kubeClient, handler.client, &deployment, &extension) + + // Test + require.NoError(t, tests.Handle(handler, tests.NewItem(t, operation.Update, extension))) + + // Refresh + refresh(t) + + // Assert + require.True(t, extension.Status.Conditions.IsTrue(networkingApi.SpecValidCondition)) + require.True(t, extension.Status.Conditions.IsTrue(networkingApi.DestinationValidCondition)) + require.True(t, extension.Status.Conditions.IsTrue(networkingApi.ReadyCondition)) + require.Equal(t, networkingApi.ArangoRouteStatusTargetRedirectType, extension.Status.Target.Type) + + require.EqualValues(t, extension.Status.Target.Path, "/ui/") + require.EqualValues(t, extension.Status.Target.Redirect.Code, goHttp.StatusTemporaryRedirect) + + c, ok := extension.Status.Conditions.Get(networkingApi.DestinationValidCondition) + require.True(t, ok) + require.EqualValues(t, c.Reason, "Destination Found") + require.EqualValues(t, c.Message, "Destination Found") + require.EqualValues(t, c.Hash, extension.Status.Target.Hash()) +} + +func Test_Handler_Redirect_Invalid_Code(t *testing.T) { + // Setup + handler := newFakeHandler() + + // Arrange + extension := tests.NewMetaObject[*networkingApi.ArangoRoute](t, tests.FakeNamespace, "test", + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Deployment = util.NewType("deployment") + }, + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Destination = &networkingApi.ArangoRouteSpecDestination{ + Redirect: &networkingApi.ArangoRouteSpecDestinationRedirect{ + Code: util.NewType(goHttp.StatusNotFound), + }, + Path: util.NewType("/ui/"), + } + }) + deployment := tests.NewMetaObject[*api.ArangoDeployment](t, tests.FakeNamespace, "deployment") + + refresh := tests.CreateObjects(t, handler.kubeClient, handler.client, &deployment, &extension) + + // Test + require.NoError(t, tests.Handle(handler, tests.NewItem(t, operation.Update, extension))) + + // Refresh + refresh(t) + + // Assert + require.False(t, extension.Status.Conditions.IsTrue(networkingApi.SpecValidCondition)) + require.False(t, extension.Status.Conditions.IsTrue(networkingApi.ReadyCondition)) + require.Nil(t, extension.Status.Target) + + c, ok := extension.Status.Conditions.Get(networkingApi.SpecValidCondition) + require.True(t, ok) + require.EqualValues(t, c.Reason, "Spec is invalid") + require.EqualValues(t, c.Message, "Received 1 errors: spec.destination.redirect.code: Invalid code. Got 404, allowed 301 & 307") +} + +func Test_Handler_Redirect_Invalid_Target(t *testing.T) { + // Setup + handler := newFakeHandler() + + // Arrange + extension := tests.NewMetaObject[*networkingApi.ArangoRoute](t, tests.FakeNamespace, "test", + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Deployment = util.NewType("deployment") + }, + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Destination = &networkingApi.ArangoRouteSpecDestination{ + Redirect: &networkingApi.ArangoRouteSpecDestinationRedirect{}, + Path: util.NewType("&643/ui/"), + } + }) + deployment := tests.NewMetaObject[*api.ArangoDeployment](t, tests.FakeNamespace, "deployment") + + refresh := tests.CreateObjects(t, handler.kubeClient, handler.client, &deployment, &extension) + + // Test + require.NoError(t, tests.Handle(handler, tests.NewItem(t, operation.Update, extension))) + + // Refresh + refresh(t) + + // Assert + require.False(t, extension.Status.Conditions.IsTrue(networkingApi.SpecValidCondition)) + require.False(t, extension.Status.Conditions.IsTrue(networkingApi.ReadyCondition)) + require.Nil(t, extension.Status.Target) + + c, ok := extension.Status.Conditions.Get(networkingApi.SpecValidCondition) + require.True(t, ok) + require.EqualValues(t, c.Reason, "Spec is invalid") + require.EqualValues(t, c.Message, "Received 1 errors: spec.destination.path: String '&643/ui/' is not a valid api path") +} + +func Test_Handler_Redirect_Invalid_Missing(t *testing.T) { + // Setup + handler := newFakeHandler() + + // Arrange + extension := tests.NewMetaObject[*networkingApi.ArangoRoute](t, tests.FakeNamespace, "test", + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Deployment = util.NewType("deployment") + }, + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Destination = &networkingApi.ArangoRouteSpecDestination{} + }) + deployment := tests.NewMetaObject[*api.ArangoDeployment](t, tests.FakeNamespace, "deployment") + + refresh := tests.CreateObjects(t, handler.kubeClient, handler.client, &deployment, &extension) + + // Test + require.NoError(t, tests.Handle(handler, tests.NewItem(t, operation.Update, extension))) + + // Refresh + refresh(t) + + // Assert + require.False(t, extension.Status.Conditions.IsTrue(networkingApi.SpecValidCondition)) + require.False(t, extension.Status.Conditions.IsTrue(networkingApi.ReadyCondition)) + require.Nil(t, extension.Status.Target) + + c, ok := extension.Status.Conditions.Get(networkingApi.SpecValidCondition) + require.True(t, ok) + require.EqualValues(t, c.Reason, "Spec is invalid") + require.EqualValues(t, c.Message, "Received 1 errors: spec.destination: Elements not provided. Expected 1. Possible: endpoints, redirect, service") +} + +func Test_Handler_Redirect_Empty(t *testing.T) { + // Setup + handler := newFakeHandler() + + // Arrange + extension := tests.NewMetaObject[*networkingApi.ArangoRoute](t, tests.FakeNamespace, "test", + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Deployment = util.NewType("deployment") + }, + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Destination = &networkingApi.ArangoRouteSpecDestination{ + Redirect: &networkingApi.ArangoRouteSpecDestinationRedirect{}, + } + }) + deployment := tests.NewMetaObject[*api.ArangoDeployment](t, tests.FakeNamespace, "deployment") + + refresh := tests.CreateObjects(t, handler.kubeClient, handler.client, &deployment, &extension) + + // Test + require.NoError(t, tests.Handle(handler, tests.NewItem(t, operation.Update, extension))) + + // Refresh + refresh(t) + + // Assert + require.True(t, extension.Status.Conditions.IsTrue(networkingApi.SpecValidCondition)) + require.True(t, extension.Status.Conditions.IsTrue(networkingApi.DestinationValidCondition)) + require.True(t, extension.Status.Conditions.IsTrue(networkingApi.ReadyCondition)) + require.Equal(t, networkingApi.ArangoRouteStatusTargetRedirectType, extension.Status.Target.Type) + + require.EqualValues(t, extension.Status.Target.Path, "/") + require.EqualValues(t, extension.Status.Target.Redirect.Code, goHttp.StatusTemporaryRedirect) + + c, ok := extension.Status.Conditions.Get(networkingApi.DestinationValidCondition) + require.True(t, ok) + require.EqualValues(t, c.Reason, "Destination Found") + require.EqualValues(t, c.Message, "Destination Found") + require.EqualValues(t, c.Hash, extension.Status.Target.Hash()) +} + +func Test_Handler_Redirect_Valid_CustomCode(t *testing.T) { + // Setup + handler := newFakeHandler() + + // Arrange + extension := tests.NewMetaObject[*networkingApi.ArangoRoute](t, tests.FakeNamespace, "test", + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Deployment = util.NewType("deployment") + }, + func(t *testing.T, obj *networkingApi.ArangoRoute) { + obj.Spec.Destination = &networkingApi.ArangoRouteSpecDestination{ + Redirect: &networkingApi.ArangoRouteSpecDestinationRedirect{ + Code: util.NewType(goHttp.StatusMovedPermanently), + }, + Path: util.NewType("/ui/"), + } + }) + deployment := tests.NewMetaObject[*api.ArangoDeployment](t, tests.FakeNamespace, "deployment") + + refresh := tests.CreateObjects(t, handler.kubeClient, handler.client, &deployment, &extension) + + // Test + require.NoError(t, tests.Handle(handler, tests.NewItem(t, operation.Update, extension))) + + // Refresh + refresh(t) + + // Assert + require.True(t, extension.Status.Conditions.IsTrue(networkingApi.SpecValidCondition)) + require.True(t, extension.Status.Conditions.IsTrue(networkingApi.DestinationValidCondition)) + require.True(t, extension.Status.Conditions.IsTrue(networkingApi.ReadyCondition)) + require.Equal(t, networkingApi.ArangoRouteStatusTargetRedirectType, extension.Status.Target.Type) + + require.EqualValues(t, extension.Status.Target.Path, "/ui/") + require.EqualValues(t, extension.Status.Target.Redirect.Code, goHttp.StatusMovedPermanently) + + c, ok := extension.Status.Conditions.Get(networkingApi.DestinationValidCondition) + require.True(t, ok) + require.EqualValues(t, c.Reason, "Destination Found") + require.EqualValues(t, c.Message, "Destination Found") + require.EqualValues(t, c.Hash, extension.Status.Target.Hash()) +} diff --git a/pkg/util/checksum.sha256.go b/pkg/util/checksum.sha256.go index 92f5f5ad1..f6777ce7f 100644 --- a/pkg/util/checksum.sha256.go +++ b/pkg/util/checksum.sha256.go @@ -38,6 +38,15 @@ func SHA256FromHashArray[T Hash](data []T) string { return t.Hash() }, data...) } +func SHA256FromNonEmptyStringArray(data ...string) string { + return SHA256FromFilteredStringArray(func(in string) bool { + return in != "" + }, data...) +} + +func SHA256FromFilteredStringArray(filter func(in string) bool, data ...string) string { + return SHA256FromStringArray(FilterList(data, filter)...) +} func SHA256FromStringArray(data ...string) string { return SHA256FromString(goStrings.Join(data, "|"))