diff --git a/README.md b/README.md index 8efb53c..ed8aa35 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Kube Pod Autocomplete is a Go-based backend service designed to enhance the user - Add caching. - Add search pods by label/ns/phase endpoint as a possible use-case. -- Add Unit-tests. +- Check URL path usage to determine resourceType. - Add e2e-tests. - Consider moving main.go to cmd. - Consider adding garden config to simplify testing. diff --git a/go.mod b/go.mod index 0b3d2a4..e12a98e 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,11 @@ require ( github.com/samber/slog-multi v1.2.1 github.com/samber/slog-syslog v1.0.0 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 k8s.io/api v0.31.0 k8s.io/apimachinery v0.31.0 k8s.io/client-go v0.31.0 + sigs.k8s.io/controller-runtime v0.19.0 ) require ( @@ -52,6 +54,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/samber/lo v1.38.1 // indirect @@ -81,7 +84,6 @@ require ( k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect - sigs.k8s.io/controller-runtime v0.19.0 sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c2a36ae --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestLoadConfig(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + wantConfig *Config + wantError error + }{ + { + name: "Default values", + envVars: map[string]string{}, + wantConfig: &Config{ + ListenAddress: defaultListenAddress, + TrustedProxies: nil, + Mode: defaultMode, + LogLevel: "info", + JSONLog: false, + LogServerAddress: "", + }, + wantError: nil, + }, + { + name: "Custom values", + envVars: map[string]string{ + "KPA_LISTEN_ADDRESS": "127.0.0.1:9090", + "KPA_TRUSTED_PROXIES": "192.168.1.1,192.168.1.2", + "KPA_MODE": gin.ReleaseMode, + "KPA_LOG_LEVEL": "debug", + "KPA_JSON_LOG": "true", + "KPA_LOG_SERVER": "logserver.local", + }, + wantConfig: &Config{ + ListenAddress: "127.0.0.1:9090", + TrustedProxies: []string{"192.168.1.1", "192.168.1.2"}, + Mode: gin.ReleaseMode, + LogLevel: "debug", + JSONLog: true, + LogServerAddress: "logserver.local", + }, + wantError: nil, + }, + } + + for _, tt := range tests { + ttp := tt + t.Run(ttp.name, func(t *testing.T) { + for envKey, envVal := range ttp.envVars { + os.Setenv(envKey, envVal) + } + t.Cleanup(func() { + os.Clearenv() + }) + + config, err := LoadConfig() + if err != nil { + assert.EqualError(t, ttp.wantError, err.Error(), "Unexpected error message") + } + + if ttp.wantConfig != nil { + assert.Equal(t, ttp.wantConfig, config, "Unexpected config") + } + + }) + } +} diff --git a/internal/handlers/autocomplete_test.go b/internal/handlers/autocomplete_test.go new file mode 100644 index 0000000..18b1169 --- /dev/null +++ b/internal/handlers/autocomplete_test.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateRequestedFilters(t *testing.T) { + tests := []struct { + name string + inputFilters []string + wantFilters []string + wantError error + }{ + { + name: "Valid filters", + inputFilters: []string{"namespace", "phase", "labels"}, + wantFilters: []string{"namespace", "phase", "labels"}, + wantError: nil, + }, + { + name: "Mixed valid and empty filters", + inputFilters: []string{"namespace", "", "phase", " ", "labels"}, + wantFilters: []string{"namespace", "phase", "labels"}, + wantError: nil, + }, + { + name: "All empty filters", + inputFilters: []string{"", " ", "\t", "\n"}, + wantFilters: nil, + wantError: errors.New("no valid filters provided"), + }, + } + + for _, tt := range tests { + ttp := tt + t.Run(ttp.name, func(t *testing.T) { + filters, err := validateRequestedFilters(ttp.inputFilters) + if err != nil { + assert.EqualError(t, ttp.wantError, err.Error(), "Unexpected error message") + } + + assert.Equal(t, ttp.wantFilters, filters, "Unexpected filters") + }) + } +} diff --git a/internal/k8s/client.go b/internal/k8s/client.go index 939fa79..c85b25a 100644 --- a/internal/k8s/client.go +++ b/internal/k8s/client.go @@ -30,7 +30,7 @@ func NewClient() (*Client, error) { return &Client{clientset: clientset}, nil } -func (c *Client) ListResource(ctx context.Context, resource model.Resource) (model.Resource, error) { +func (c *Client) ListResource(ctx context.Context, resource model.Resources) (model.Resources, error) { switch resource.(type) { case model.ResourceType: return c.listPods(ctx) diff --git a/internal/services/autocomplete/autocomplete.go b/internal/services/autocomplete/autocomplete.go index a1b10e4..61a28f8 100644 --- a/internal/services/autocomplete/autocomplete.go +++ b/internal/services/autocomplete/autocomplete.go @@ -49,7 +49,7 @@ func (s *Service) GetAutocompleteSuggestions(ctx context.Context, req model.Auto } // extractSuggestions extracts suggestions from the given pods based on the requested filters -func (s *Service) extractSuggestions(resources model.Resource, filters *map[string]model.FieldFilter) (*model.AutocompleteSuggestions, error) { +func (s *Service) extractSuggestions(resources model.Resources, filters *map[string]model.FieldFilter) (*model.AutocompleteSuggestions, error) { suggestions := make([]model.Suggestion, 0, len(*filters)) for fieldName, fieldFilter := range *filters { extractedData := fieldFilter.Extractor.Extract(resources) @@ -87,10 +87,10 @@ func (s *Service) extractSuggestions(resources model.Resource, filters *map[stri return &model.AutocompleteSuggestions{Suggestions: suggestions}, nil } -func (s *Service) processMapSuggestion(suggestions *[]model.Suggestion, filter string, mapData *map[string][]string) { +func (s *Service) processMapSuggestion(suggestions *[]model.Suggestion, filterName string, mapData *map[string][]string) { for key, value := range *mapData { *suggestions = append(*suggestions, model.Suggestion{ - Key: fmt.Sprintf("%s:%s", filter, key), + Key: fmt.Sprintf("%s:%s", filterName, key), Values: value, }) } diff --git a/internal/services/autocomplete/autocomplete_test.go b/internal/services/autocomplete/autocomplete_test.go new file mode 100644 index 0000000..3096557 --- /dev/null +++ b/internal/services/autocomplete/autocomplete_test.go @@ -0,0 +1,45 @@ +package autocomplete + +import ( + "testing" + + "github.com/csatib02/kube-pod-autocomplete/internal/services/autocomplete/model" + "github.com/stretchr/testify/assert" +) + +var serviceTest = Service{} + +func TestProcessMapSuggestion(t *testing.T) { + tests := []struct { + name string + filterName string + mapData map[string][]string + inputSuggestions []model.Suggestion + wantSuggestions []model.Suggestion + }{ + { + name: "Valid Map Data", + filterName: "labels", + mapData: map[string][]string{"app": {"nginx", "redis"}}, + inputSuggestions: []model.Suggestion{}, + wantSuggestions: []model.Suggestion{ + {Key: "labels:app", Values: []string{"nginx", "redis"}}, + }, + }, + { + name: "Empty Map Data", + filterName: "labels", + mapData: map[string][]string{}, + inputSuggestions: []model.Suggestion{}, + wantSuggestions: []model.Suggestion{}, + }, + } + + for _, tt := range tests { + ttp := tt + t.Run(ttp.name, func(t *testing.T) { + serviceTest.processMapSuggestion(&ttp.inputSuggestions, ttp.filterName, &ttp.mapData) + assert.Equal(t, ttp.wantSuggestions, ttp.inputSuggestions) + }) + } +} diff --git a/internal/services/autocomplete/filter/filteroptions_test.go b/internal/services/autocomplete/filter/filteroptions_test.go new file mode 100644 index 0000000..f97f1a5 --- /dev/null +++ b/internal/services/autocomplete/filter/filteroptions_test.go @@ -0,0 +1,103 @@ +package filter + +import ( + "testing" + + "github.com/csatib02/kube-pod-autocomplete/internal/services/autocomplete/model" + "github.com/stretchr/testify/assert" +) + +var optionsTest = Options{} + +func TestRemoveDuplicateValues(t *testing.T) { + tests := []struct { + name string + inputsuggestions []model.Suggestion + wantSuggestions []model.Suggestion + }{ + { + name: "No duplicates", + inputsuggestions: []model.Suggestion{ + {Key: "namespace", Values: []string{"value1", "value2"}}, + {Key: "label", Values: []string{"value3", "value4"}}, + }, + wantSuggestions: []model.Suggestion{ + {Key: "namespace", Values: []string{"value1", "value2"}}, + {Key: "label", Values: []string{"value3", "value4"}}, + }, + }, + { + name: "With duplicates", + inputsuggestions: []model.Suggestion{ + {Key: "namespace", Values: []string{"value1", "value2", "value1"}}, + {Key: "label", Values: []string{"value3", "value4", "value3"}}, + }, + wantSuggestions: []model.Suggestion{ + {Key: "namespace", Values: []string{"value1", "value2"}}, + {Key: "label", Values: []string{"value3", "value4"}}, + }, + }, + { + name: "All duplicates", + inputsuggestions: []model.Suggestion{ + {Key: "namespace", Values: []string{"value1", "value1", "value1"}}, + }, + wantSuggestions: []model.Suggestion{ + {Key: "namespace", Values: []string{"value1"}}, + }, + }, + } + + for _, tt := range tests { + ttp := tt + t.Run(ttp.name, func(t *testing.T) { + optionsTest.RemoveDuplicateValues(&ttp.inputsuggestions) + assert.Equal(t, ttp.wantSuggestions, ttp.inputsuggestions) + }) + } +} + +func TestRemoveIgnoredKeys(t *testing.T) { + tests := []struct { + name string + inputsuggestions []model.Suggestion + wantSuggestions []model.Suggestion + }{ + { + name: "No ignored keys", + inputsuggestions: []model.Suggestion{ + {Key: "namespace", Values: []string{"value1", "value2"}}, + {Key: "label", Values: []string{"value3", "value4"}}, + }, + wantSuggestions: []model.Suggestion{ + {Key: "namespace", Values: []string{"value1", "value2"}}, + {Key: "label", Values: []string{"value3", "value4"}}, + }, + }, + { + name: "With ignored keys", + inputsuggestions: []model.Suggestion{ + {Key: "annotations:kubectl.kubernetes.io/last-applied-configuration", Values: []string{"value1"}}, + {Key: "label", Values: []string{"value2"}}, + }, + wantSuggestions: []model.Suggestion{ + {Key: "label", Values: []string{"value2"}}, + }, + }, + { + name: "All ignored keys", + inputsuggestions: []model.Suggestion{ + {Key: "annotations:kubectl.kubernetes.io/last-applied-configuration", Values: []string{"value1"}}, + }, + wantSuggestions: []model.Suggestion{}, + }, + } + + for _, tt := range tests { + ttp := tt + t.Run(ttp.name, func(t *testing.T) { + optionsTest.RemoveIgnoredKeys(&ttp.inputsuggestions) + assert.Equal(t, ttp.wantSuggestions, ttp.inputsuggestions) + }) + } +} diff --git a/internal/services/autocomplete/filter/podfilter/filter.go b/internal/services/autocomplete/filter/podfilter/podfilter.go similarity index 75% rename from internal/services/autocomplete/filter/podfilter/filter.go rename to internal/services/autocomplete/filter/podfilter/podfilter.go index 4d6fd72..ec706f3 100644 --- a/internal/services/autocomplete/filter/podfilter/filter.go +++ b/internal/services/autocomplete/filter/podfilter/podfilter.go @@ -7,8 +7,8 @@ import ( var supportedFilters = map[string]model.FieldFilter{ "namespace": { Type: model.ListFilter, - Extractor: model.ListExtractor(func(resource model.Resource) interface{} { - podResource := resource.(model.PodResource) + Extractor: model.ListExtractor(func(resource model.Resources) interface{} { + podResource := resource.(model.PodResources) result := make([]string, 0, len(podResource.Items)) for _, pod := range podResource.Items { result = append(result, pod.Namespace) @@ -18,8 +18,8 @@ var supportedFilters = map[string]model.FieldFilter{ }, "phase": { Type: model.ListFilter, - Extractor: model.ListExtractor(func(resource model.Resource) interface{} { - podResource := resource.(model.PodResource) + Extractor: model.ListExtractor(func(resource model.Resources) interface{} { + podResource := resource.(model.PodResources) result := make([]string, 0, len(podResource.Items)) for _, pod := range podResource.Items { result = append(result, string(pod.Status.Phase)) @@ -29,8 +29,8 @@ var supportedFilters = map[string]model.FieldFilter{ }, "labels": { Type: model.MapFilter, - Extractor: model.MapExtractor(func(resource model.Resource) interface{} { - podResource := resource.(model.PodResource) + Extractor: model.MapExtractor(func(resource model.Resources) interface{} { + podResource := resource.(model.PodResources) result := make(map[string][]string) for _, pod := range podResource.Items { for key, value := range pod.Labels { @@ -42,8 +42,8 @@ var supportedFilters = map[string]model.FieldFilter{ }, "annotations": { Type: model.MapFilter, - Extractor: model.MapExtractor(func(resource model.Resource) interface{} { - podResource := resource.(model.PodResource) + Extractor: model.MapExtractor(func(resource model.Resources) interface{} { + podResource := resource.(model.PodResources) result := make(map[string][]string) for _, pod := range podResource.Items { for key, value := range pod.Annotations { @@ -58,7 +58,7 @@ var supportedFilters = map[string]model.FieldFilter{ // GetFilters returns the supported filters based on the requested filters // if called with empty requestedFilters or nil, it returns all supported filters func GetFilters(requestedFilters *[]string) *map[string]model.FieldFilter { - if len(*requestedFilters) == 0 && requestedFilters == nil { + if requestedFilters == nil || len(*requestedFilters) == 0 { return &supportedFilters } diff --git a/internal/services/autocomplete/model/extractor.go b/internal/services/autocomplete/model/extractor.go index cf10262..c0fec2c 100644 --- a/internal/services/autocomplete/model/extractor.go +++ b/internal/services/autocomplete/model/extractor.go @@ -3,19 +3,19 @@ package model // FieldExtractor interface defines the method for extracting field values from a PodList // NOTE: There is no actual difference between ListExtractor and MapExtractor, // since when processing the extracted data, we can always check the type of the underlying data structure -// via FieldFilter.FieldType, but for the sake of clarity, I have defined two separate types. +// via FieldFilter.Type, but for the sake of clarity, I have defined two separate types. type FieldExtractor interface { - Extract(Resource) any + Extract(Resources) any } -type ListExtractor func(resource Resource) interface{} +type ListExtractor func(resource Resources) interface{} -func (e ListExtractor) Extract(resource Resource) interface{} { +func (e ListExtractor) Extract(resource Resources) interface{} { return e(resource) } -type MapExtractor func(resource Resource) interface{} +type MapExtractor func(resource Resources) interface{} -func (e MapExtractor) Extract(resource Resource) interface{} { +func (e MapExtractor) Extract(resource Resources) interface{} { return e(resource) } diff --git a/internal/services/autocomplete/model/resource.go b/internal/services/autocomplete/model/resource.go index c441270..4f55249 100644 --- a/internal/services/autocomplete/model/resource.go +++ b/internal/services/autocomplete/model/resource.go @@ -9,7 +9,7 @@ const ( PodResourceType ResourceType = "Pod" ) -// Resource is an interface that represents the actual resource type -type Resource interface{} +// Resources is an interface that represents the actual resource type +type Resources interface{} -type PodResource = *v1.PodList +type PodResources = *v1.PodList