diff --git a/config/crd/bases/http.keda.sh_httpscaledobjects.yaml b/config/crd/bases/http.keda.sh_httpscaledobjects.yaml index fce12d7d3..c4f0060de 100644 --- a/config/crd/bases/http.keda.sh_httpscaledobjects.yaml +++ b/config/crd/bases/http.keda.sh_httpscaledobjects.yaml @@ -60,6 +60,27 @@ spec: spec: description: HTTPScaledObjectSpec defines the desired state of HTTPScaledObject properties: + headers: + description: |- + The custom headers used to route. Once Hosts and PathPrefixes have been matched, + if at least one header in the http request matches at least one header + in .spec.headers, it will be routed to the Service and Port specified in + the scaleTargetRef. First header it matches with, it will be routed to. + If the headers can't be matched, then use first one without .spec.headers supplied + If that doesn't exist then routing will fail. + items: + description: Header contains the definition for matching on header + name and/or value + properties: + name: + type: string + value: + type: string + required: + - name + - value + type: object + type: array hosts: description: |- The hosts to route. All requests which the "Host" header diff --git a/operator/apis/http/v1alpha1/httpscaledobject_types.go b/operator/apis/http/v1alpha1/httpscaledobject_types.go index bfa1099a9..23aa8fc8b 100644 --- a/operator/apis/http/v1alpha1/httpscaledobject_types.go +++ b/operator/apis/http/v1alpha1/httpscaledobject_types.go @@ -76,6 +76,12 @@ type RateMetricSpec struct { Granularity metav1.Duration `json:"granularity" description:"Time granularity for rate calculation"` } +// Header contains the definition for matching on header name and/or value +type Header struct { + Name string `json:"name"` + Value string `json:"value"` +} + // HTTPScaledObjectSpec defines the desired state of HTTPScaledObject type HTTPScaledObjectSpec struct { // The hosts to route. All requests which the "Host" header @@ -89,6 +95,14 @@ type HTTPScaledObjectSpec struct { // the scaleTargetRef. // +optional PathPrefixes []string `json:"pathPrefixes,omitempty"` + // The custom headers used to route. Once Hosts and PathPrefixes have been matched, + // if at least one header in the http request matches at least one header + // in .spec.headers, it will be routed to the Service and Port specified in + // the scaleTargetRef. First header it matches with, it will be routed to. + // If the headers can't be matched, then use first one without .spec.headers supplied + // If that doesn't exist then routing will fail. + // +optional + Headers []Header `json:"headers,omitempty"` // The name of the deployment to route HTTP requests to (and to autoscale). // Including validation as a requirement to define either the PortName or the Port // +kubebuilder:validation:XValidation:rule="has(self.portName) != has(self.port)",message="must define either the 'portName' or the 'port'" diff --git a/operator/apis/http/v1alpha1/zz_generated.deepcopy.go b/operator/apis/http/v1alpha1/zz_generated.deepcopy.go index 50cd83461..6c6677f1f 100644 --- a/operator/apis/http/v1alpha1/zz_generated.deepcopy.go +++ b/operator/apis/http/v1alpha1/zz_generated.deepcopy.go @@ -145,6 +145,11 @@ func (in *HTTPScaledObjectSpec) DeepCopyInto(out *HTTPScaledObjectSpec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Headers != nil { + in, out := &in.Headers, &out.Headers + *out = make([]Header, len(*in)) + copy(*out, *in) + } out.ScaleTargetRef = in.ScaleTargetRef if in.Replicas != nil { in, out := &in.Replicas, &out.Replicas @@ -203,6 +208,21 @@ func (in *HTTPScaledObjectStatus) DeepCopy() *HTTPScaledObjectStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Header) DeepCopyInto(out *Header) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Header. +func (in *Header) DeepCopy() *Header { + if in == nil { + return nil + } + out := new(Header) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RateMetricSpec) DeepCopyInto(out *RateMetricSpec) { *out = *in diff --git a/pkg/routing/httpso_index.go b/pkg/routing/httpso_index.go new file mode 100644 index 000000000..655b60096 --- /dev/null +++ b/pkg/routing/httpso_index.go @@ -0,0 +1,35 @@ +package routing + +import ( + iradix "github.com/hashicorp/go-immutable-radix/v2" + + httpv1alpha1 "github.com/kedacore/http-add-on/operator/apis/http/v1alpha1" +) + +type httpSOIndex struct { + radix *iradix.Tree[*httpv1alpha1.HTTPScaledObject] +} + +func newHTTPSOIndex() *httpSOIndex { + return &httpSOIndex{radix: iradix.New[*httpv1alpha1.HTTPScaledObject]()} +} + +func (hi *httpSOIndex) insert(key tableMemoryIndexKey, httpso *httpv1alpha1.HTTPScaledObject) (*httpSOIndex, *httpv1alpha1.HTTPScaledObject, bool) { + newRadix, oldVal, oldSet := hi.radix.Insert(key, httpso) + newHTTPSOIndex := &httpSOIndex{ + radix: newRadix, + } + return newHTTPSOIndex, oldVal, oldSet +} + +func (hi *httpSOIndex) get(key tableMemoryIndexKey) (*httpv1alpha1.HTTPScaledObject, bool) { + return hi.radix.Get(key) +} + +func (hi *httpSOIndex) delete(key tableMemoryIndexKey) (*httpSOIndex, *httpv1alpha1.HTTPScaledObject, bool) { + newRadix, oldVal, oldSet := hi.radix.Delete(key) + newHTTPSOIndex := &httpSOIndex{ + radix: newRadix, + } + return newHTTPSOIndex, oldVal, oldSet +} diff --git a/pkg/routing/httpso_index_test.go b/pkg/routing/httpso_index_test.go new file mode 100644 index 000000000..3edb003ac --- /dev/null +++ b/pkg/routing/httpso_index_test.go @@ -0,0 +1,157 @@ +package routing + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + httpv1alpha1 "github.com/kedacore/http-add-on/operator/apis/http/v1alpha1" + "github.com/kedacore/http-add-on/pkg/k8s" +) + +var _ = Describe("httpSOIndex", func() { + var ( + httpso0 = &httpv1alpha1.HTTPScaledObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "keda-sh", + }, + Spec: httpv1alpha1.HTTPScaledObjectSpec{ + Hosts: []string{ + "keda.sh", + }, + }, + } + + httpso0NamespacedName = k8s.NamespacedNameFromObject(httpso0) + httpso0IndexKey = newTableMemoryIndexKey(httpso0NamespacedName) + + httpso1 = &httpv1alpha1.HTTPScaledObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "one-one-one-one", + }, + Spec: httpv1alpha1.HTTPScaledObjectSpec{ + Hosts: []string{ + "1.1.1.1", + }, + }, + } + httpso1NamespacedName = k8s.NamespacedNameFromObject(httpso1) + httpso1IndexKey = newTableMemoryIndexKey(httpso1NamespacedName) + ) + Context("New", func() { + It("returns a httpSOIndex with initialized tree", func() { + index := newHTTPSOIndex() + Expect(index.radix).NotTo(BeNil()) + }) + }) + + Context("Get / Insert", func() { + It("Get on empty httpSOIndex returns nil", func() { + index := newHTTPSOIndex() + _, ok := index.get(httpso0IndexKey) + Expect(ok).To(BeFalse()) + }) + It("httpSOIndex insert will return previous object if set", func() { + index := newHTTPSOIndex() + index, prevVal, prevSet := index.insert(httpso0IndexKey, httpso0) + Expect(prevSet).To(BeFalse()) + Expect(prevVal).To(BeNil()) + httpso0Copy := httpso0.DeepCopy() + httpso0Copy.Name = "httpso0Copy" + index, prevVal, prevSet = index.insert(httpso0IndexKey, httpso0Copy) + Expect(prevSet).To(BeTrue()) + Expect(prevVal).To(Equal(httpso0)) + Expect(prevVal).ToNot(Equal(httpso0Copy)) + httpso, ok := index.get(httpso0IndexKey) + Expect(ok).To(BeTrue()) + Expect(httpso).ToNot(Equal(httpso0)) + Expect(httpso).To(Equal(httpso0Copy)) + }) + + It("httpSOIndex with new object inserted returns object", func() { + index := newHTTPSOIndex() + index, httpso, prevSet := index.insert(httpso0IndexKey, httpso0) + Expect(prevSet).To(BeFalse()) + Expect(httpso).To(BeNil()) + httpso, ok := index.get(httpso0IndexKey) + Expect(ok).To(BeTrue()) + Expect(httpso).To(Equal(httpso0)) + }) + + It("httpSOIndex with new object inserted retains other object", func() { + index := newHTTPSOIndex() + + index, _, _ = index.insert(httpso0IndexKey, httpso0) + httpso, ok := index.get(httpso0IndexKey) + Expect(ok).To(BeTrue()) + Expect(httpso).To(Equal(httpso0)) + + _, ok = index.get(httpso1IndexKey) + Expect(ok).To(BeFalse()) + + index, _, _ = index.insert(httpso1IndexKey, httpso1) + httpso, ok = index.get(httpso1IndexKey) + Expect(ok).To(BeTrue()) + Expect(httpso).To(Equal(httpso1)) + + // httpso0 still there + httpso, ok = index.get(httpso0IndexKey) + Expect(ok).To(BeTrue()) + Expect(httpso).To(Equal(httpso0)) + }) + }) + + Context("Get / Delete", func() { + It("delete on empty httpSOIndex returns nil", func() { + index := newHTTPSOIndex() + _, httpso, oldSet := index.delete(httpso0IndexKey) + Expect(httpso).To(BeNil()) + Expect(oldSet).To(BeFalse()) + }) + + It("double delete returns nil the second time", func() { + index := newHTTPSOIndex() + index, _, _ = index.insert(httpso0IndexKey, httpso0) + index, _, _ = index.insert(httpso1IndexKey, httpso1) + index, deletedVal, oldSet := index.delete(httpso0IndexKey) + Expect(deletedVal).To(Equal(httpso0)) + Expect(oldSet).To(BeTrue()) + _, deletedVal, oldSet = index.delete(httpso0IndexKey) + Expect(deletedVal).To(BeNil()) + Expect(oldSet).To(BeFalse()) + }) + + It("delete on httpSOIndex removes object ", func() { + index := newHTTPSOIndex() + index, _, _ = index.insert(httpso0IndexKey, httpso0) + httpso, ok := index.get(httpso0IndexKey) + Expect(ok).To(BeTrue()) + Expect(httpso).To(Equal(httpso0)) + index, deletedVal, oldSet := index.delete(httpso0IndexKey) + Expect(deletedVal).To(Equal(httpso0)) + Expect(oldSet).To(BeTrue()) + httpso, ok = index.get(httpso0IndexKey) + Expect(httpso).To(BeNil()) + Expect(ok).To(BeFalse()) + }) + + It("httpSOIndex delete on one object does not affect other", func() { + index := newHTTPSOIndex() + + index, _, _ = index.insert(httpso0IndexKey, httpso0) + index, _, _ = index.insert(httpso1IndexKey, httpso1) + httpso, ok := index.get(httpso0IndexKey) + Expect(ok).To(BeTrue()) + Expect(httpso).To(Equal(httpso0)) + index, deletedVal, oldSet := index.delete(httpso1IndexKey) + Expect(deletedVal).To(Equal(httpso1)) + Expect(oldSet).To(BeTrue()) + httpso, ok = index.get(httpso0IndexKey) + Expect(ok).To(BeTrue()) + Expect(httpso).To(Equal(httpso0)) + httpso, ok = index.get(httpso1IndexKey) + Expect(ok).To(BeFalse()) + Expect(httpso).To(BeNil()) + }) + }) +}) diff --git a/pkg/routing/httpso_store.go b/pkg/routing/httpso_store.go new file mode 100644 index 000000000..6def6e046 --- /dev/null +++ b/pkg/routing/httpso_store.go @@ -0,0 +1,105 @@ +package routing + +import ( + iradix "github.com/hashicorp/go-immutable-radix/v2" + + httpv1alpha1 "github.com/kedacore/http-add-on/operator/apis/http/v1alpha1" + "github.com/kedacore/http-add-on/pkg/k8s" +) + +// light wrapper around radix tree containing HTTPScaledObjectList +// with convenience functions to manage CRUD for individual HTTPScaledObject. +// created as an abstraction to manage complexity for tablememory implementation +// the store is meant to map host + path keys to one or more HTTPScaledObject +// and return one arbitrarily or route based on headers +type httpSOStore struct { + radix *iradix.Tree[httpv1alpha1.HTTPScaledObjectList] +} + +func newHTTPSOStore() *httpSOStore { + return &httpSOStore{radix: iradix.New[httpv1alpha1.HTTPScaledObjectList]()} +} + +// Insert key value into httpSOStore +// Gets old list of HTTPScaledObjectList +// if exists appends to list and returns new httpSOStore +// with new radix tree +func (hs *httpSOStore) append(key Key, httpso *httpv1alpha1.HTTPScaledObject) *httpSOStore { + httpsoList, found := hs.radix.Get(key) + var newHTTPSOStore *httpSOStore + if !found { + newList := httpv1alpha1.HTTPScaledObjectList{Items: []httpv1alpha1.HTTPScaledObject{*httpso}} + newRadix, _, _ := hs.radix.Insert(key, newList) + newHTTPSOStore = &httpSOStore{ + radix: newRadix, + } + } else { + found = false + var newList httpv1alpha1.HTTPScaledObjectList + for i, httpsoItem := range httpsoList.Items { + if httpsoItem.Name == httpso.Name && httpsoItem.Namespace == httpso.Namespace { + httpsoList.Items[i] = *httpso + found = true + newList = httpsoList + break + } + } + if !found { + newList = httpv1alpha1.HTTPScaledObjectList{Items: append(httpsoList.Items, *httpso)} + } + newRadix, _, _ := hs.radix.Insert(key, newList) + newHTTPSOStore = &httpSOStore{ + radix: newRadix, + } + } + return newHTTPSOStore +} + +func (hs *httpSOStore) insert(key Key, httpsoList httpv1alpha1.HTTPScaledObjectList) (*httpSOStore, httpv1alpha1.HTTPScaledObjectList, bool) { + newRadix, oldVal, ok := hs.radix.Insert(key, httpsoList) + newHTTPSOStore := &httpSOStore{ + radix: newRadix, + } + return newHTTPSOStore, oldVal, ok +} + +func (hs *httpSOStore) get(key Key) (httpv1alpha1.HTTPScaledObjectList, bool) { + return hs.radix.Get(key) +} + +func (hs *httpSOStore) delete(key Key) (*httpSOStore, httpv1alpha1.HTTPScaledObjectList, bool) { + newRadix, oldVal, oldSet := hs.radix.Delete(key) + newHTTPSOStore := &httpSOStore{ + radix: newRadix, + } + return newHTTPSOStore, oldVal, oldSet +} + +// convenience function +// retrieves all keys associated with HTTPScaledObject +// and deletes it from every list in the store +func (hs *httpSOStore) DeleteAllInstancesOfHTTPSO(httpso *httpv1alpha1.HTTPScaledObject) *httpSOStore { + httpsoNamespacedName := k8s.NamespacedNameFromObject(httpso) + newHTTPSOStore := &httpSOStore{radix: hs.radix} + keys := NewKeysFromHTTPSO(httpso) + for _, key := range keys { + httpsoList, _ := newHTTPSOStore.radix.Get(key) + for i, httpso := range httpsoList.Items { + // delete only if namespaced names match + if currHttpsoNamespacedName := k8s.NamespacedNameFromObject(&httpso); *httpsoNamespacedName == *currHttpsoNamespacedName { + httpsoList.Items = append(httpsoList.Items[:i], httpsoList.Items[i+1:]...) + break + } + } + if len(httpsoList.Items) == 0 { + newHTTPSOStore.radix, _, _ = newHTTPSOStore.radix.Delete(key) + } else { + newHTTPSOStore.radix, _, _ = newHTTPSOStore.radix.Insert(key, httpsoList) + } + } + return newHTTPSOStore +} + +func (hs *httpSOStore) GetLongestPrefix(key Key) ([]byte, httpv1alpha1.HTTPScaledObjectList, bool) { + return hs.radix.Root().LongestPrefix(key) +} diff --git a/pkg/routing/httpso_store_test.go b/pkg/routing/httpso_store_test.go new file mode 100644 index 000000000..ad6993f39 --- /dev/null +++ b/pkg/routing/httpso_store_test.go @@ -0,0 +1,234 @@ +package routing + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + httpv1alpha1 "github.com/kedacore/http-add-on/operator/apis/http/v1alpha1" +) + +var _ = Describe("httpSOStore", func() { + var ( + httpso0 = &httpv1alpha1.HTTPScaledObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "keda-sh", + }, + Spec: httpv1alpha1.HTTPScaledObjectSpec{ + Hosts: []string{ + "keda.sh", + }, + }, + } + httpso0List = httpv1alpha1.HTTPScaledObjectList{ + Items: []httpv1alpha1.HTTPScaledObject{ + *httpso0, + }, + } + + httpso0StoreKeys = NewKeysFromHTTPSO(httpso0) + httpso1 = &httpv1alpha1.HTTPScaledObject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "one-one-one-one", + }, + Spec: httpv1alpha1.HTTPScaledObjectSpec{ + Hosts: []string{ + "1.1.1.1", + }, + }, + } + httpso1List = httpv1alpha1.HTTPScaledObjectList{ + Items: []httpv1alpha1.HTTPScaledObject{ + *httpso1, + }, + } + httpso1StoreKeys = NewKeysFromHTTPSO(httpso1) + ) + Context("New", func() { + It("returns a httpSOStore with initialized tree", func() { + store := newHTTPSOStore() + Expect(store.radix).NotTo(BeNil()) + }) + }) + + Context("Get / Insert", func() { + It("Get on empty httpSOStore returns nil", func() { + store := newHTTPSOStore() + for _, key := range httpso0StoreKeys { + _, ok := store.get(key) + Expect(ok).To(BeFalse()) + } + }) + + It("httpSOStore with new object inserted returns old object", func() { + store := newHTTPSOStore() + for _, key := range httpso0StoreKeys { + var prevVal httpv1alpha1.HTTPScaledObjectList + var prevSet bool + store, prevVal, prevSet = store.insert(key, httpso0List) + Expect(len(prevVal.Items)).To(Equal(0)) + Expect(prevSet).To(BeFalse()) + } + httpso0ListCopy := httpv1alpha1.HTTPScaledObjectList{ + Items: httpso0List.Items, + ListMeta: metav1.ListMeta{ + ResourceVersion: "httpso0ListCopy", + }, + } + for _, key := range httpso0StoreKeys { + var prevVal httpv1alpha1.HTTPScaledObjectList + var prevSet bool + store, prevVal, prevSet = store.insert(key, httpso0ListCopy) + Expect(prevVal).To(Equal(httpso0List)) + Expect(prevVal).ToNot(Equal(httpso0ListCopy)) + Expect(prevSet).To(BeTrue()) + } + }) + + It("httpSOStore insert will return object if set", func() { + store := newHTTPSOStore() + for _, key := range httpso0StoreKeys { + var prevVal httpv1alpha1.HTTPScaledObjectList + var prevSet bool + store, prevVal, prevSet = store.insert(key, httpso0List) + Expect(len(prevVal.Items)).To(Equal(0)) + Expect(prevSet).To(BeFalse()) + } + for _, key := range httpso0StoreKeys { + httpsoList, ok := store.get(key) + Expect(httpsoList).To(Equal(httpso0List)) + Expect(ok).To(BeTrue()) + } + }) + + It("httpSOStore with new object inserted retains other object", func() { + store := newHTTPSOStore() + for _, key := range httpso0StoreKeys { + store, _, _ = store.insert(key, httpso0List) + } + + for _, key := range httpso0StoreKeys { + httpsoList, ok := store.get(key) + Expect(ok).To(BeTrue()) + Expect(httpsoList).To(Equal(httpso0List)) + } + + for _, key := range httpso1StoreKeys { + httpsoList, ok := store.get(key) + Expect(ok).To(BeFalse()) + Expect(len(httpsoList.Items)).To(Equal(0)) + } + + for _, key := range httpso1StoreKeys { + store, _, _ = store.insert(key, httpso1List) + } + + for _, key := range httpso1StoreKeys { + httpsoList, ok := store.get(key) + Expect(ok).To(BeTrue()) + Expect(httpsoList).To(Equal(httpso1List)) + } + + for _, key := range httpso0StoreKeys { + httpsoList, ok := store.get(key) + Expect(ok).To(BeTrue()) + Expect(httpsoList).To(Equal(httpso0List)) + } + }) + }) + + Context("Get / Delete", func() { + It("delete on empty httpSOStore returns nil", func() { + store := newHTTPSOStore() + for _, key := range httpso0StoreKeys { + var prevVal httpv1alpha1.HTTPScaledObjectList + var prevSet bool + store, prevVal, prevSet = store.delete(key) + Expect(len(prevVal.Items)).To(Equal(0)) + Expect(prevSet).To(BeFalse()) + } + }) + + It("double delete returns nil the second time", func() { + store := newHTTPSOStore() + for _, key := range httpso0StoreKeys { + store, _, _ = store.insert(key, httpso0List) + } + for _, key := range httpso1StoreKeys { + store, _, _ = store.insert(key, httpso1List) + } + for _, key := range httpso0StoreKeys { + var prevVal httpv1alpha1.HTTPScaledObjectList + var prevSet bool + store, prevVal, prevSet = store.delete(key) + Expect(prevVal).To(Equal(httpso0List)) + Expect(prevSet).To(BeTrue()) + } + for _, key := range httpso0StoreKeys { + var prevVal httpv1alpha1.HTTPScaledObjectList + var prevSet bool + store, prevVal, prevSet = store.delete(key) + Expect(len(prevVal.Items)).To(Equal(0)) + Expect(prevSet).To(BeFalse()) + } + }) + + It("delete on httpSOStore removes object ", func() { + store := newHTTPSOStore() + for _, key := range httpso0StoreKeys { + store, _, _ = store.insert(key, httpso0List) + } + for _, key := range httpso0StoreKeys { + httpsoList, ok := store.get(key) + Expect(ok).To(BeTrue()) + Expect(httpsoList).To(Equal(httpso0List)) + } + + for _, key := range httpso0StoreKeys { + var prevVal httpv1alpha1.HTTPScaledObjectList + var prevSet bool + store, prevVal, prevSet = store.delete(key) + Expect(prevVal).To(Equal(httpso0List)) + Expect(prevSet).To(BeTrue()) + } + for _, key := range httpso0StoreKeys { + httpsoList, ok := store.get(key) + Expect(len(httpsoList.Items)).To(Equal(0)) + Expect(ok).To(BeFalse()) + } + }) + + It("httpSOStore delete on one object does not affect other", func() { + store := newHTTPSOStore() + for _, key := range httpso0StoreKeys { + store, _, _ = store.insert(key, httpso0List) + } + for _, key := range httpso1StoreKeys { + store, _, _ = store.insert(key, httpso1List) + } + for _, key := range httpso0StoreKeys { + httpsoList, ok := store.get(key) + Expect(ok).To(BeTrue()) + Expect(httpsoList).To(Equal(httpso0List)) + } + for _, key := range httpso1StoreKeys { + var prevVal httpv1alpha1.HTTPScaledObjectList + var prevSet bool + store, prevVal, prevSet = store.delete(key) + Expect(prevVal).To(Equal(httpso1List)) + Expect(prevSet).To(BeTrue()) + } + + for _, key := range httpso0StoreKeys { + httpsoList, ok := store.get(key) + Expect(ok).To(BeTrue()) + Expect(httpsoList).To(Equal(httpso0List)) + } + for _, key := range httpso1StoreKeys { + httpsoList, ok := store.get(key) + Expect(ok).To(BeFalse()) + Expect(len(httpsoList.Items)).To(Equal(0)) + } + }) + }) +}) diff --git a/pkg/routing/table.go b/pkg/routing/table.go index 4cf8a5309..16635fde7 100644 --- a/pkg/routing/table.go +++ b/pkg/routing/table.go @@ -135,7 +135,11 @@ func (t *table) Route(req *http.Request) *httpv1alpha1.HTTPScaledObject { } key := NewKeyFromRequest(req) - return tm.Route(key) + hso := tm.RouteWithHeaders(key, req.Header) + if hso.Name == "" { + return nil + } + return &hso } func (t *table) HasSynced() bool { diff --git a/pkg/routing/tablememory.go b/pkg/routing/tablememory.go index 0cb0d8f10..028bd5a83 100644 --- a/pkg/routing/tablememory.go +++ b/pkg/routing/tablememory.go @@ -1,7 +1,8 @@ package routing import ( - iradix "github.com/hashicorp/go-immutable-radix/v2" + "net/textproto" + "k8s.io/apimachinery/pkg/types" httpv1alpha1 "github.com/kedacore/http-add-on/operator/apis/http/v1alpha1" @@ -13,20 +14,25 @@ type TableMemory interface { Recall(namespacedName *types.NamespacedName) *httpv1alpha1.HTTPScaledObject Forget(namespacedName *types.NamespacedName) TableMemory Route(key Key) *httpv1alpha1.HTTPScaledObject + RouteWithHeaders(key Key, httpHeaders map[string][]string) httpv1alpha1.HTTPScaledObject } type tableMemory struct { - index *iradix.Tree[*httpv1alpha1.HTTPScaledObject] - store *iradix.Tree[*httpv1alpha1.HTTPScaledObject] + index *httpSOIndex + store *httpSOStore } -func NewTableMemory() TableMemory { +func newTableMemory() tableMemory { return tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), + index: newHTTPSOIndex(), + store: newHTTPSOStore(), } } +func NewTableMemory() TableMemory { + return newTableMemory() +} + var _ TableMemory = (*tableMemory)(nil) func (tm tableMemory) Remember(httpso *httpv1alpha1.HTTPScaledObject) TableMemory { @@ -36,21 +42,13 @@ func (tm tableMemory) Remember(httpso *httpv1alpha1.HTTPScaledObject) TableMemor httpso = httpso.DeepCopy() indexKey := newTableMemoryIndexKeyFromHTTPSO(httpso) - index, _, _ := tm.index.Insert(indexKey, httpso) + index, _, _ := tm.index.insert(indexKey, httpso) keys := NewKeysFromHTTPSO(httpso) store := tm.store for _, key := range keys { - newStore, oldHTTPSO, _ := store.Insert(key, httpso) - - // oldest HTTPScaledObject has precedence - if oldHTTPSO != nil && httpso.GetCreationTimestamp().Time.After(oldHTTPSO.GetCreationTimestamp().Time) { - continue - } - - store = newStore + store = store.append(key, httpso) } - return tableMemory{ index: index, store: store, @@ -63,7 +61,7 @@ func (tm tableMemory) Recall(namespacedName *types.NamespacedName) *httpv1alpha1 } indexKey := newTableMemoryIndexKey(namespacedName) - httpso, _ := tm.index.Get(indexKey) + httpso, _ := tm.index.get(indexKey) if httpso == nil { return nil } @@ -75,26 +73,12 @@ func (tm tableMemory) Forget(namespacedName *types.NamespacedName) TableMemory { if namespacedName == nil { return nil } - indexKey := newTableMemoryIndexKey(namespacedName) - index, httpso, _ := tm.index.Delete(indexKey) - if httpso == nil { + index, httpso, oldSet := tm.index.delete(indexKey) + if httpso == nil || !oldSet { return tm } - - keys := NewKeysFromHTTPSO(httpso) - store := tm.store - for _, key := range keys { - newStore, oldHTTPSO, _ := store.Delete(key) - - // delete only if namespaced names match - if oldNamespacedName := k8s.NamespacedNameFromObject(oldHTTPSO); oldNamespacedName == nil || *oldNamespacedName != *namespacedName { - continue - } - - store = newStore - } - + store := tm.store.DeleteAllInstancesOfHTTPSO(httpso) return tableMemory{ index: index, store: store, @@ -102,8 +86,45 @@ func (tm tableMemory) Forget(namespacedName *types.NamespacedName) TableMemory { } func (tm tableMemory) Route(key Key) *httpv1alpha1.HTTPScaledObject { - _, httpso, _ := tm.store.Root().LongestPrefix(key) - return httpso + _, httpsoList, _ := tm.store.GetLongestPrefix(key) + if len(httpsoList.Items) == 0 { + return nil + } + return httpsoList.Items[0].DeepCopy() +} + +func (tm tableMemory) RouteWithHeaders(key Key, httpHeaders map[string][]string) httpv1alpha1.HTTPScaledObject { + _, httpsoList, _ := tm.store.GetLongestPrefix(key) + if len(httpsoList.Items) == 0 { + return httpv1alpha1.HTTPScaledObject{} + } + if len(httpHeaders) == 0 { + return httpsoList.Items[0] + } + var httpsoWithoutHeaders httpv1alpha1.HTTPScaledObject + + // route to first httpso which has a matching header + for _, httpso := range httpsoList.Items { + // TODO: check this code thoroughly, not sure it does what it claims to do + if httpso.Spec.Headers != nil { + for _, header := range httpso.Spec.Headers { + // normalize header spacing how golang does it + canonicalHeaderName := textproto.CanonicalMIMEHeaderKey(header.Name) + if headerValues, exists := httpHeaders[canonicalHeaderName]; exists { + for _, v := range headerValues { + if header.Value == v { + return httpso + } + } + } + } + } else if httpsoWithoutHeaders.Name == "" { + httpsoWithoutHeaders = httpso + } + } + + // if no matches via header, route to httpso without headers supplied, if any + return httpsoWithoutHeaders } type tableMemoryIndexKey []byte diff --git a/pkg/routing/tablememory_test.go b/pkg/routing/tablememory_test.go index 79eaac041..a351b63a2 100644 --- a/pkg/routing/tablememory_test.go +++ b/pkg/routing/tablememory_test.go @@ -5,9 +5,9 @@ import ( "net/url" "time" - iradix "github.com/hashicorp/go-immutable-radix/v2" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/onsi/gomega/types" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" @@ -118,7 +118,7 @@ var _ = Describe("TableMemory", func() { namespacedName := k8s.NamespacedNameFromObject(input) indexKey := newTableMemoryIndexKey(namespacedName) - httpso, ok := tm.index.Get(indexKey) + httpso, ok := tm.index.get(indexKey) Expect(ok).To(okMatcher) Expect(httpso).To(httpsoMatcher) } @@ -129,16 +129,22 @@ var _ = Describe("TableMemory", func() { okMatcher = BeFalse() } - httpsoMatcher := Equal(expected) + var httpsoMatcher types.GomegaMatcher if expected == nil { httpsoMatcher = BeNil() + } else { + httpsoMatcher = ContainElements(*expected) } storeKeys := NewKeysFromHTTPSO(input) for _, storeKey := range storeKeys { - httpso, ok := tm.store.Get(storeKey) + httpSOList, ok := tm.store.get(storeKey) Expect(ok).To(okMatcher) - Expect(httpso).To(httpsoMatcher) + if len(httpSOList.Items) == 0 { + Expect(httpSOList.Items).To(httpsoMatcher) + } else { + Expect(httpSOList.Items).To(httpsoMatcher) + } } } @@ -150,7 +156,7 @@ var _ = Describe("TableMemory", func() { insertIndex = func(tm tableMemory, httpso *httpv1alpha1.HTTPScaledObject) tableMemory { namespacedName := k8s.NamespacedNameFromObject(httpso) indexKey := newTableMemoryIndexKey(namespacedName) - tm.index, _, _ = tm.index.Insert(indexKey, httpso) + tm.index, _, _ = tm.index.insert(indexKey, httpso) return tm } @@ -158,7 +164,7 @@ var _ = Describe("TableMemory", func() { insertStore = func(tm tableMemory, httpso *httpv1alpha1.HTTPScaledObject) tableMemory { storeKeys := NewKeysFromHTTPSO(httpso) for _, storeKey := range storeKeys { - tm.store, _, _ = tm.store.Insert(storeKey, httpso) + tm.store, _, _ = tm.store.insert(storeKey, httpv1alpha1.HTTPScaledObjectList{Items: []httpv1alpha1.HTTPScaledObject{*httpso}}) } return tm @@ -184,20 +190,14 @@ var _ = Describe("TableMemory", func() { Context("Remember", func() { It("returns a tableMemory with new object inserted", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = tm.Remember(&httpso0).(tableMemory) assertTrees(tm, &httpso0, &httpso0) }) It("returns a tableMemory with new object inserted and other objects retained", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = tm.Remember(&httpso0).(tableMemory) tm = tm.Remember(&httpso1).(tableMemory) @@ -206,10 +206,7 @@ var _ = Describe("TableMemory", func() { }) It("returns a tableMemory with old object of same key replaced", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = tm.Remember(&httpso0).(tableMemory) httpso1 := *httpso0.DeepCopy() @@ -220,10 +217,7 @@ var _ = Describe("TableMemory", func() { }) It("returns a tableMemory with old object of same key replaced and other objects retained", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = tm.Remember(&httpso0).(tableMemory) tm = tm.Remember(&httpso1).(tableMemory) @@ -236,10 +230,7 @@ var _ = Describe("TableMemory", func() { }) It("returns a tableMemory with deep-copied object", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() httpso := *httpso0.DeepCopy() tm = tm.Remember(&httpso).(tableMemory) @@ -249,10 +240,7 @@ var _ = Describe("TableMemory", func() { }) It("gives precedence to the oldest object on conflict", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() t0 := time.Now() @@ -290,10 +278,7 @@ var _ = Describe("TableMemory", func() { Context("Forget", func() { It("returns a tableMemory with old object deleted", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = insertTrees(tm, &httpso0) tm = tm.Forget(&httpso0NamespacedName).(tableMemory) @@ -302,10 +287,7 @@ var _ = Describe("TableMemory", func() { }) It("returns a tableMemory with old object deleted and other objects retained", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = insertTrees(tm, &httpso0) tm = insertTrees(tm, &httpso1) @@ -316,10 +298,7 @@ var _ = Describe("TableMemory", func() { }) It("returns unchanged tableMemory when object is absent", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = insertTrees(tm, &httpso0) index0 := *tm.index @@ -332,10 +311,7 @@ var _ = Describe("TableMemory", func() { }) It("forgets only when namespaced names match on conflict", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = insertTrees(tm, &httpso0) t0 := time.Now() @@ -377,10 +353,7 @@ var _ = Describe("TableMemory", func() { Context("Recall", func() { It("returns object with matching key", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = insertTrees(tm, &httpso0) httpso := tm.Recall(&httpso0NamespacedName) @@ -388,10 +361,7 @@ var _ = Describe("TableMemory", func() { }) It("returns nil when object is absent", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = insertTrees(tm, &httpso0) httpso := tm.Recall(&httpso1NamespacedName) @@ -399,10 +369,7 @@ var _ = Describe("TableMemory", func() { }) It("returns deep-copied object", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = insertTrees(tm, &httpso0) httpso := tm.Recall(&httpso0NamespacedName) @@ -416,10 +383,7 @@ var _ = Describe("TableMemory", func() { Context("Route", func() { It("returns nil when no matching host for URL", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = insertTrees(tm, &httpso0) url, err := url.Parse(fmt.Sprintf("https://%s.br", httpso0.Spec.Hosts[0])) @@ -432,10 +396,7 @@ var _ = Describe("TableMemory", func() { }) It("returns expected object with matching host for URL", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = insertTrees(tm, &httpso0) tm = insertTrees(tm, &httpso1) @@ -462,10 +423,7 @@ var _ = Describe("TableMemory", func() { httpsoFoo = httpsoList.Items[3] ) - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() tm = insertTrees(tm, &httpsoFoo) //goland:noinspection HttpUrlsUsage @@ -479,10 +437,7 @@ var _ = Describe("TableMemory", func() { }) It("returns expected object with matching pathPrefix for URL", func() { - tm := tableMemory{ - index: iradix.New[*httpv1alpha1.HTTPScaledObject](), - store: iradix.New[*httpv1alpha1.HTTPScaledObject](), - } + tm := newTableMemory() for _, httpso := range httpsoList.Items { httpso := httpso @@ -579,7 +534,7 @@ var _ = Describe("TableMemory", func() { tm = tm.Remember(&httpso) ret9 := tm.Route(url1Key) - Expect(ret9).To(Equal(&httpso)) + Expect(*ret9).To(Equal(httpso)) }) }) })