diff --git a/cfg/resource.go b/cfg/resource.go deleted file mode 100644 index f5e2844..0000000 --- a/cfg/resource.go +++ /dev/null @@ -1,67 +0,0 @@ -package cfg - -import ( - "time" -) - -//KubernetesResource kubernetes resource interface -type KubernetesResource struct { - name string - namespace string - kind string - creationTimestamp time.Time - labels map[string]string -} - -//GetName returns name of the resource -func (res *KubernetesResource) GetName() string { return res.name } - -//SetName sets name of the resource -func (res *KubernetesResource) SetName(name string) { res.name = name } - -//GetNamespace returns namespace of the resource -func (res *KubernetesResource) GetNamespace() string { return res.namespace } - -//SetNamespace sets namespace of the resource -func (res *KubernetesResource) SetNamespace(namespace string) { res.namespace = namespace } - -//GetKind returns type of the resource -func (res *KubernetesResource) GetKind() string { return res.kind } - -//SetKind sets type of the resource -func (res *KubernetesResource) SetKind(kind string) { res.kind = kind } - -//GetCreationTimestamp returns creation date of the resource -func (res *KubernetesResource) GetCreationTimestamp() time.Time { return res.creationTimestamp } - -//SetCreationTimestamp sets creation date of the resource -func (res *KubernetesResource) SetCreationTimestamp(creationTimestamp time.Time) { - res.creationTimestamp = creationTimestamp -} - -//GetLabels returns labels of the resource -func (res *KubernetesResource) GetLabels() map[string]string { return res.labels } - -//SetLabels sets labels of the resource -func (res *KubernetesResource) SetLabels(labels map[string]string) { res.labels = labels } - -//NewResource resource constructor -func NewResource(name, namespace, kind string, creationTimestamp time.Time, labels map[string]string) KubernetesResource { - return KubernetesResource{ - name: name, - namespace: namespace, - kind: kind, - creationTimestamp: creationTimestamp, - labels: labels, - } -} - -//NewConfigMapResource config map resource constructor -func NewConfigMapResource(name, namespace string, creationTimestamp time.Time, labels map[string]string) KubernetesResource { - return NewResource(name, namespace, "ConfigMap", creationTimestamp, labels) -} - -//NewSecretResource secret resource constructor -func NewSecretResource(name, namespace string, creationTimestamp time.Time, labels map[string]string) KubernetesResource { - return NewResource(name, namespace, "Secret", creationTimestamp, labels) -} diff --git a/cfg/types.go b/cfg/types.go index a3fd4dd..20b815d 100644 --- a/cfg/types.go +++ b/cfg/types.go @@ -46,6 +46,12 @@ type ( Labels []string `koanf:"label"` OlderThan string `koanf:"older-than"` } + // CoreObjectInterface defines interface for core kubernetes resources + CoreObjectInterface interface { + Delete(name string, options *metav1.DeleteOptions) error + } + // ResourceNamespaceSelector gets resource from client + ResourceNamespaceSelector func(*core.CoreV1Client) CoreObjectInterface ) // NewDefaultConfig retrieves the hardcoded configs with sane defaults @@ -82,11 +88,3 @@ func NewDefaultConfig() *Configuration { }, } } - -//CoreObjectInterface defines interface for core kubernetes resources -type CoreObjectInterface interface { - Delete(name string, options *metav1.DeleteOptions) error -} - -//ResourceNamespaceSelector gets resource from client -type ResourceNamespaceSelector func(*core.CoreV1Client) CoreObjectInterface diff --git a/cmd/root.go b/cmd/root.go index c033abc..b94562a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,7 +1,6 @@ package cmd import ( - "io/ioutil" "os" "strings" @@ -34,7 +33,8 @@ func init() { rootCmd.PersistentFlags().StringP("namespace", "n", config.Namespace, "Cluster namespace of current context") rootCmd.PersistentFlags().String("log.level", config.Log.LogLevel, "Log level, one of [debug info warn error fatal]") rootCmd.PersistentFlags().BoolP("log.verbose", "v", config.Log.Verbose, "Shorthand for --log.level debug") - rootCmd.PersistentFlags().BoolP("log.batch", "b", config.Log.Batch, "Use Batch mode (disables logging, prints deleted images only)") + rootCmd.PersistentFlags().BoolP("log.batch", "b", config.Log.Batch, + "Use Batch mode (Prints error to StdErr, StdOut is used to just print resource names, useful for piping)") cobra.OnInitialize(initRootConfig) } @@ -56,14 +56,15 @@ func parseConfig(cmd *cobra.Command, args []string) { DisableTimestamp: true, }) - if config.Log.Batch { - log.SetOutput(ioutil.Discard) - } else { - log.SetOutput(os.Stderr) - } if config.Log.Verbose { config.Log.LogLevel = "debug" } + if config.Log.Batch { + log.SetOutput(os.Stderr) + config.Log.LogLevel = "error" + } else { + log.SetOutput(os.Stdout) + } level, err := log.ParseLevel(config.Log.LogLevel) if err != nil { log.WithError(err).Warn("Could not parse log level, fallback to info level") diff --git a/cmd/secrets.go b/cmd/secrets.go index 369a456..368df62 100644 --- a/cmd/secrets.go +++ b/cmd/secrets.go @@ -77,12 +77,12 @@ func executeSecretCleanupCommand(service secret.Service) error { foundSecrets, err := service.List(getListOptions(c.Labels)) if err != nil { - return fmt.Errorf("Could not retrieve secrets with labels '%s' for '%s': %w", c.Labels, namespace, err) + return fmt.Errorf("could not retrieve secrets with labels '%s' for '%s': %w", c.Labels, namespace, err) } unusedSecrets, err := service.GetUnused(namespace, foundSecrets) if err != nil { - return fmt.Errorf("Could not retrieve unused secrets for '%s': %w", namespace, err) + return fmt.Errorf("could not retrieve unused secrets for '%s': %w", namespace, err) } cutOffDateTime, _ := parseCutOffDateTime(c.OlderThan) @@ -91,7 +91,10 @@ func executeSecretCleanupCommand(service secret.Service) error { filteredSecrets = service.FilterByMaxCount(filteredSecrets, config.History.Keep) if config.Delete { - service.Delete(filteredSecrets) + err := service.Delete(filteredSecrets) + if err != nil { + return fmt.Errorf("could not delete secrets for '%s': %s", namespace, err) + } } else { log.Infof("Showing results for --keep=%d and --older-than=%s", config.History.Keep, c.OlderThan) service.Print(filteredSecrets) diff --git a/pkg/configmap/configmap.go b/pkg/configmap/configmap.go index d74edae..c49aeef 100644 --- a/pkg/configmap/configmap.go +++ b/pkg/configmap/configmap.go @@ -17,12 +17,21 @@ import ( type ( Service interface { + // PrintNamesAndLabels return names and labels of ConfigMaps PrintNamesAndLabels(namespace string) error + // List returns a list of ConfigMaps from a namespace List(listOptions metav1.ListOptions) (configMaps []v1.ConfigMap, err error) + // GetUnused return unused ConfigMaps GetUnused(namespace string, configMaps []v1.ConfigMap) (unusedConfigMaps []v1.ConfigMap, funcErr error) - Delete(configMaps []v1.ConfigMap) + // Delete removes the given ConfigMaps + Delete(configMaps []v1.ConfigMap) error + // FilterByTime returns ConfigMaps which are older than specified date FilterByTime(configMaps []v1.ConfigMap, olderThan time.Time) (filteredConfigMaps []v1.ConfigMap) + // FilterByMaxCount returns the latest resources until limited by . The list of ConfigMaps is sorted by + // CreationTimestamp, with newest entries first. FilterByMaxCount(configMaps []v1.ConfigMap, keep int) (filteredConfigMaps []v1.ConfigMap) + // Print outputs the given ConfigMaps line by line. In batch mode, only the ConfigMap name is printed, otherwise default + // log with info level Print(configMaps []v1.ConfigMap) } ConfigMapsService struct { @@ -44,25 +53,18 @@ func NewConfigMapsService(client core.ConfigMapInterface, helper kubernetes.Kube } } -// PrintNamesAndLabels return names and labels of Config Maps func (cms ConfigMapsService) PrintNamesAndLabels(namespace string) error { configMaps, err := cms.List(metav1.ListOptions{}) if err != nil { return err } - var objectMetas []metav1.ObjectMeta - for _, cm := range configMaps { - objectMetas = append(objectMetas, cm.ObjectMeta) - } log.Infof("Following Config Maps are available in namespace %s", namespace) - namesAndLabels := util.GetNamesAndLabels(objectMetas) - for name, labels := range namesAndLabels { - log.Infof("Name: %s, labels: %s", name, labels) + for _, cm := range configMaps { + log.Infof("Name: %s, labels: %s", cm.Name, util.FlattenStringMap(cm.Labels)) } return nil } -// List returns a list of ConfigMaps from a namespace func (cms ConfigMapsService) List(listOptions metav1.ListOptions) ([]v1.ConfigMap, error) { configMaps, err := cms.client.List(listOptions) if err != nil { @@ -72,7 +74,6 @@ func (cms ConfigMapsService) List(listOptions metav1.ListOptions) ([]v1.ConfigMa return configMaps.Items, nil } -// GetUnused return unused Config Maps func (cms ConfigMapsService) GetUnused(namespace string, configMaps []v1.ConfigMap) (unusedConfigMaps []v1.ConfigMap, funcErr error) { var usedConfigMaps []v1.ConfigMap funk.ForEach(openshift.PredefinedResources, func(predefinedResource schema.GroupVersionResource) { @@ -105,76 +106,52 @@ func (cms ConfigMapsService) GetUnused(namespace string, configMaps []v1.ConfigM return unusedConfigMaps, funcErr } -// Delete removes Config Maps -func (cms ConfigMapsService) Delete(configMaps []v1.ConfigMap) { +func (cms ConfigMapsService) Delete(configMaps []v1.ConfigMap) error { for _, resource := range configMaps { - namespace := resource.Namespace - name := resource.Name - + err := cms.client.Delete(resource.Name, &metav1.DeleteOptions{}) + if err != nil { + return err + } if cms.configuration.Batch { - fmt.Println(name) + fmt.Println(resource.Name) } else { - log.Infof("Deleting configmap %s/%s", namespace, name) - } - - err := cms.client.Delete(name, &metav1.DeleteOptions{}) - - if err != nil { - log.WithError(err).Errorf("Failed to delete configmap %s/%s", namespace, name) + log.Infof("Deleted ConfigMap %s/%s", resource.Namespace, resource.Name) } } + return nil } -//FilterByTime returns config maps which are older than specified date func (cms ConfigMapsService) FilterByTime(configMaps []v1.ConfigMap, olderThan time.Time) (filteredResources []v1.ConfigMap) { log.WithFields(log.Fields{ "olderThan": olderThan, }).Debug("Filtering resources older than the specified time") for _, resource := range configMaps { - lastUpdatedDate := resource.GetCreationTimestamp() - // In case the creation date is null (isZero()) treat as oldest - if lastUpdatedDate.IsZero() || lastUpdatedDate.Time.Before(olderThan) { + if util.IsOlderThan(&resource, olderThan) { filteredResources = append(filteredResources, resource) - log.WithFields(log.Fields{ - "configMap": resource.Name, - }).Debug("Filtering resource") - } else { - log.WithField("name", resource.GetName()).Debug("Filtered resource") } } - return filteredResources } -// FilterByMaxCount keep at most n newest resources. The list of config maps is sorted in descending ordered in func (cms ConfigMapsService) FilterByMaxCount(configMaps []v1.ConfigMap, keep int) (filteredResources []v1.ConfigMap) { - log.WithFields(log.Fields{ - "keep": keep, - "configMaps": configMaps, - }).Debug("Filtering ordered by time Resources from the n'th number specified") + "keep": keep, + }).Debug("Filtering out oldest resources to a capped amount") + + if len(configMaps) <= keep { + return []v1.ConfigMap{} + } sort.SliceStable(configMaps, func(i, j int) bool { timestampFirst := configMaps[j].GetCreationTimestamp() timestampSecond := configMaps[i].GetCreationTimestamp() - if timestampFirst.IsZero() || timestampFirst.IsZero() && timestampSecond.IsZero() { - return true - } else if timestampSecond.IsZero() { - return false - } - return timestampFirst.Time.Before(timestampSecond.Time) + return util.CompareTimestamps(timestampFirst, timestampSecond) }) - if len(configMaps) <= keep { - return []v1.ConfigMap{} - } - return configMaps[keep:] } -// Print prints the given resource line by line. In batch mode, only the resource is printed, otherwise default -// log with info level func (cms ConfigMapsService) Print(resources []v1.ConfigMap) { if len(resources) == 0 { log.Info("Nothing found to be deleted.") @@ -185,7 +162,7 @@ func (cms ConfigMapsService) Print(resources []v1.ConfigMap) { } } else { for _, resource := range resources { - log.Infof("Found candidate: %s/%s", resource.Namespace, resource.GetName()) + log.Infof("Found candidate: %s/%s", resource.Namespace, resource.Name) } } } diff --git a/pkg/configmap/configmap_test.go b/pkg/configmap/configmap_test.go index 799ca9d..06cc9f8 100644 --- a/pkg/configmap/configmap_test.go +++ b/pkg/configmap/configmap_test.go @@ -8,7 +8,6 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes/fake" - core "k8s.io/client-go/kubernetes/typed/core/v1" test "k8s.io/client-go/testing" "testing" "time" @@ -36,35 +35,39 @@ func Test_PrintNamesAndLabels(t *testing.T) { tests := []struct { name string configMaps []v1.ConfigMap - err error + expectErr bool + reaction test.ReactionFunc }{ { name: "GivenListOfConfigMaps_WhenListError_ThenReturnError", configMaps: []v1.ConfigMap{}, - err: errors.New("error config map"), + expectErr: true, + reaction: createErrorReactor(), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - clientset := fake.NewSimpleClientset() - clientset.PrependReactor("list", "configmaps", func(action test.Action) (handled bool, ret runtime.Object, err error) { - return true, &v1.ConfigMapList{}, tt.err - }) - fakeConfigMapsInterface := clientset.CoreV1().ConfigMaps(testNamespace) - service := NewConfigMapsService(fakeConfigMapsInterface, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) + clientset := fake.NewSimpleClientset(convertToRuntime(tt.configMaps)[:]...) + clientset.PrependReactor("list", "configmaps", tt.reaction) + fakeClient := clientset.CoreV1().ConfigMaps(testNamespace) + service := NewConfigMapsService(fakeClient, &HelperKubernetes{}, ServiceConfiguration{}) err := service.PrintNamesAndLabels(testNamespace) - assert.EqualError(t, err, tt.err.Error()) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } }) } } - func Test_List(t *testing.T) { tests := []struct { name string configMaps []v1.ConfigMap - err error + expectErr bool + reaction test.ReactionFunc }{ { name: "GivenListOfConfigMaps_WhenAllPresent_ThenReturnAllOfThem", @@ -77,32 +80,27 @@ func Test_List(t *testing.T) { { name: "GivenListOfConfigMap_WhenListError_ThenReturnError", configMaps: []v1.ConfigMap{}, - err: errors.New("error configmap"), + reaction: createErrorReactor(), + expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var fakeConfigMapInterface core.ConfigMapInterface - if len(tt.configMaps) != 0 { - fakeConfigMapInterface = fake.NewSimpleClientset(&tt.configMaps[0], &tt.configMaps[1]).CoreV1().ConfigMaps(testNamespace) - } else { - clientset := fake.NewSimpleClientset() - clientset.PrependReactor("list", "configmaps", func(action test.Action) (handled bool, ret runtime.Object, err error) { - return true, &v1.ConfigMapList{}, tt.err - }) - fakeConfigMapInterface = clientset.CoreV1().ConfigMaps(testNamespace) + clientset := fake.NewSimpleClientset(convertToRuntime(tt.configMaps)[:]...) + if tt.reaction != nil { + clientset.PrependReactor("list", "configmaps", tt.reaction) } - - service := NewConfigMapsService(fakeConfigMapInterface, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) + fakeClient := clientset.CoreV1().ConfigMaps(testNamespace) + service := NewConfigMapsService(fakeClient, &HelperKubernetes{}, ServiceConfiguration{}) list, err := service.List(metav1.ListOptions{}) - if tt.err == nil { - assert.NoError(t, err) - assert.ElementsMatch(t, tt.configMaps, list) - } else { - assert.EqualError(t, err, tt.err.Error()) + if tt.expectErr { + assert.Error(t, err) + return } + assert.NoError(t, err) + assert.ElementsMatch(t, tt.configMaps, list) }) } } @@ -110,42 +108,33 @@ func Test_List(t *testing.T) { func Test_FilterByTime(t *testing.T) { tests := []struct { - name string - configMaps []v1.ConfigMap - filteredConfigMaps []v1.ConfigMap - cutOffDate time.Time - err error + name string + configMaps []v1.ConfigMap + expectedResult []v1.ConfigMap + cutOffDate time.Time + err error }{ { name: "GivenListOfConfigMaps_WhenFilteredByTime_ThenReturnASubsetOfConfigMaps", configMaps: generateBaseTestConfigMaps(), - filteredConfigMaps: []v1.ConfigMap{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "nameB", - Namespace: testNamespace, - CreationTimestamp: metav1.Time{ - Time: time.Date(2010, 1, 1, 1, 0, 0, 0, time.UTC), - }, - Labels: map[string]string{"keyB": "valueB", "keyC": "valueC"}, - }, - }, + expectedResult: []v1.ConfigMap{ + generateBaseTestConfigMaps()[1], }, cutOffDate: time.Date(2015, 1, 1, 1, 0, 0, 0, time.UTC), }, { - name: "GivenListOfConfigMaps_WhenFilteredBefore2010_ThenReturnEmptyList", - configMaps: generateBaseTestConfigMaps(), - filteredConfigMaps: []v1.ConfigMap{}, - cutOffDate: time.Date(2005, 1, 1, 1, 0, 0, 0, time.UTC), + name: "GivenListOfConfigMaps_WhenFilteredBefore2010_ThenReturnEmptyList", + configMaps: generateBaseTestConfigMaps(), + expectedResult: []v1.ConfigMap{}, + cutOffDate: time.Date(2005, 1, 1, 1, 0, 0, 0, time.UTC), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - fakeConfigMapInterface := fake.NewSimpleClientset(&tt.configMaps[0], &tt.configMaps[1]).CoreV1().ConfigMaps(testNamespace) - service := NewConfigMapsService(fakeConfigMapInterface, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) + fakeClient := fake.NewSimpleClientset(&tt.configMaps[0], &tt.configMaps[1]).CoreV1().ConfigMaps(testNamespace) + service := NewConfigMapsService(fakeClient, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) filteredConfigMaps := service.FilterByTime(tt.configMaps, tt.cutOffDate) - assert.ElementsMatch(t, filteredConfigMaps, tt.filteredConfigMaps) + assert.ElementsMatch(t, filteredConfigMaps, tt.expectedResult) }) } } @@ -163,16 +152,7 @@ func Test_FilterByMaxCount(t *testing.T) { name: "GivenListOfConfigMaps_FilterByMaxCountOne_ThenReturnOneConfigMap", configMaps: generateBaseTestConfigMaps(), filteredConfigMaps: []v1.ConfigMap{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "nameB", - Namespace: testNamespace, - CreationTimestamp: metav1.Time{ - Time: time.Date(2010, 1, 1, 1, 0, 0, 0, time.UTC), - }, - Labels: map[string]string{"keyB": "valueB", "keyC": "valueC"}, - }, - }, + generateBaseTestConfigMaps()[1], }, keep: 1, }, @@ -201,43 +181,41 @@ func Test_FilterByMaxCount(t *testing.T) { func Test_Delete(t *testing.T) { tests := []struct { - name string - configMaps []v1.ConfigMap - err error + name string + configMaps []v1.ConfigMap + expectErr bool + reaction test.ReactionFunc + expectedRemaining []v1.ConfigMap }{ { - name: "GivenASetOfConfigMaps_WhenAllPresent_ThenDeleteAllOfThem", - configMaps: generateBaseTestConfigMaps(), + name: "GivenASetOfConfigMaps_WhenAllPresent_ThenDeleteAllOfThem", + configMaps: generateBaseTestConfigMaps(), + expectedRemaining: []v1.ConfigMap{}, }, { - name: "GivenASetOfConfigMaps_WhenError_ThenReturnError", - configMaps: generateBaseTestConfigMaps(), - err: errors.New("ConfigMap error"), + name: "GivenASetOfConfigMaps_WhenError_ThenReturnError", + configMaps: generateBaseTestConfigMaps(), + expectedRemaining: generateBaseTestConfigMaps(), + reaction: createErrorReactor(), + expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var fakeConfigMapInterface core.ConfigMapInterface - if tt.err == nil { - fakeConfigMapInterface = fake.NewSimpleClientset(&tt.configMaps[0], &tt.configMaps[1]).CoreV1().ConfigMaps(testNamespace) - } else { - clientset := fake.NewSimpleClientset(&tt.configMaps[0], &tt.configMaps[1]) - clientset.PrependReactor("delete", "configmaps", func(action test.Action) (handled bool, ret runtime.Object, err error) { - return true, nil, tt.err - }) - fakeConfigMapInterface = clientset.CoreV1().ConfigMaps(testNamespace) + clientset := fake.NewSimpleClientset(convertToRuntime(tt.configMaps)[:]...) + if tt.reaction != nil { + clientset.PrependReactor("delete", "configmaps", tt.reaction) } - service := NewConfigMapsService(fakeConfigMapInterface, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) - service.Delete(tt.configMaps) - list, err := fakeConfigMapInterface.List(metav1.ListOptions{}) - - assert.NoError(t, err) - if tt.err == nil { - assert.EqualValues(t, 0, len(list.Items)) - } else { - assert.EqualValues(t, 2, len(list.Items)) + fakeClient := clientset.CoreV1().ConfigMaps(testNamespace) + service := NewConfigMapsService(fakeClient, &HelperKubernetes{}, ServiceConfiguration{}) + err := service.Delete(tt.configMaps) + if tt.expectErr { + assert.Error(t, err) } + list, err := fakeClient.List(metav1.ListOptions{}) + assert.NoError(t, err) + assert.ElementsMatch(t, tt.expectedRemaining, list.Items) }) } } @@ -314,3 +292,16 @@ func generateBaseTestConfigMaps() []v1.ConfigMap { }, } } + +func convertToRuntime(cm []v1.ConfigMap) (objects []runtime.Object) { + for _, s := range cm { + objects = append(objects, s.DeepCopyObject()) + } + return objects +} + +func createErrorReactor() test.ReactionFunc { + return func(action test.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, errors.New("error") + } +} diff --git a/pkg/kubernetes/clients.go b/pkg/kubernetes/clients.go index 0e753b6..d9ae260 100644 --- a/pkg/kubernetes/clients.go +++ b/pkg/kubernetes/clients.go @@ -2,31 +2,9 @@ package kubernetes import ( "k8s.io/client-go/dynamic" - apps "k8s.io/client-go/kubernetes/typed/apps/v1" - batch "k8s.io/client-go/kubernetes/typed/batch/v1beta1" core "k8s.io/client-go/kubernetes/typed/core/v1" ) -// NewAppsV1Client for current kubeconfig -func NewAppsV1Client() (*apps.AppsV1Client, error) { - restConfig, err := RestConfig() - if err != nil { - return nil, err - } - - return apps.NewForConfig(restConfig) -} - -// NewBatchV1beta1Client creates a new BatchV1beta1Client for the current kubeconfig -func NewBatchV1beta1Client() (*batch.BatchV1beta1Client, error) { - restConfig, err := RestConfig() - if err != nil { - return nil, err - } - - return batch.NewForConfig(restConfig) -} - // NewDynamicClient creates a new dynamic client func NewDynamicClient() (dynamic.Interface, error) { restConfig, err := RestConfig() diff --git a/pkg/openshift/resources.go b/pkg/openshift/resources.go index ca064ac..4ff9f79 100644 --- a/pkg/openshift/resources.go +++ b/pkg/openshift/resources.go @@ -1,7 +1,6 @@ package openshift import ( - "github.com/appuio/seiso/cfg" "github.com/appuio/seiso/pkg/kubernetes" imagev1 "github.com/openshift/api/image/v1" log "github.com/sirupsen/logrus" @@ -99,70 +98,3 @@ func ListImageStreams(namespace string) ([]imagev1.ImageStream, error) { } return imageStreams.Items, nil } - -// ListConfigMaps returns a list of ConfigMaps from a namspace that have these labels -func ListConfigMaps(namespace string, listOptions metav1.ListOptions) (resources []cfg.KubernetesResource, err error) { - coreClient, err := kubernetes.NewCoreV1Client() - if err != nil { - return nil, err - } - - configMaps, err := coreClient.ConfigMaps(namespace).List(listOptions) - if err != nil { - return nil, err - } - - for _, configMap := range configMaps.Items { - resource := cfg.NewConfigMapResource( - configMap.GetName(), - configMap.GetNamespace(), - configMap.GetCreationTimestamp().Time, - configMap.GetLabels()) - resources = append(resources, resource) - } - - return resources, nil -} - -// ListUnusedResources lists resources that are unused -func ListUnusedResources(namespace string, resources []cfg.KubernetesResource) (unusedResources []cfg.KubernetesResource, funcErr error) { - var usedResources []cfg.KubernetesResource - funk.ForEach(PredefinedResources, func(predefinedResource schema.GroupVersionResource) { - funk.ForEach(resources, func(resource cfg.KubernetesResource) { - - resourceName := resource.GetName() - - if funk.Contains(usedResources, resource) { - // already marked as existing, skip this - return - } - contains, err := helper.ResourceContains(namespace, resourceName, predefinedResource) - if err != nil { - funcErr = err - return - } - - if contains { - usedResources = append(usedResources, resource) - } - }) - }) - - for _, resource := range resources { - if !funk.Contains(usedResources, resource) { - unusedResources = append(unusedResources, resource) - } - } - - return unusedResources, funcErr -} - -// DeleteResource permanently deletes a resource -func DeleteResource(resource string, resourceSelectorFunc cfg.ResourceNamespaceSelector) error { - coreClient, err := kubernetes.NewCoreV1Client() - if err != nil { - return err - } - - return resourceSelectorFunc(coreClient).Delete(resource, &metav1.DeleteOptions{}) -} diff --git a/pkg/secret/secret.go b/pkg/secret/secret.go index 94c271e..ee8af99 100644 --- a/pkg/secret/secret.go +++ b/pkg/secret/secret.go @@ -17,12 +17,21 @@ import ( type ( Service interface { + // PrintNamesAndLabels return the names and labels of all secrets PrintNamesAndLabels(namespace string) error + // List returns a list of Secrets from a namespace List(listOptions metav1.ListOptions) (resources []v1.Secret, err error) + // GetUnused returns unused Secrets. GetUnused(namespace string, secrets []v1.Secret) (unusedSecrets []v1.Secret, funcErr error) - Delete(secrets []v1.Secret) + // Delete removes the given Secrets. Errors are logged only. + Delete(secrets []v1.Secret) error + // FilterByTime returns Secrets which are older than specified date FilterByTime(secrets []v1.Secret, olderThan time.Time) (filteredSecrets []v1.Secret) + // FilterByMaxCount returns the latest resources until limited by . The list of secrets is sorted by + // CreationTimestamp, with newest entries first. FilterByMaxCount(secrets []v1.Secret, keep int) (filteredSecrets []v1.Secret) + // Print prints the given Secrets line by line. In batch mode, only the Secret name is printed, otherwise default + // log with info level Print(secrets []v1.Secret) } @@ -45,25 +54,18 @@ func NewSecretsService(client core.SecretInterface, helper kubernetes.Kubernetes } } -// PrintNamesAndLabels return the names and labels of all secrets func (ss SecretsService) PrintNamesAndLabels(namespace string) error { secrets, err := ss.List(metav1.ListOptions{}) if err != nil { return err } - var objectMetas []metav1.ObjectMeta - for _, s := range secrets { - objectMetas = append(objectMetas, s.ObjectMeta) - } log.Infof("Following Secrets are available in namespace %s", namespace) - namesAndLabels := util.GetNamesAndLabels(objectMetas) - for name, labels := range namesAndLabels { - log.Infof("Name: %s, labels: %s", name, labels) + for _, s := range secrets { + log.Infof("Name: %s, labels: %s", s.Name, util.FlattenStringMap(s.Labels)) } return nil } -// List returns a list of secrets from a namespace func (ss SecretsService) List(listOptions metav1.ListOptions) ([]v1.Secret, error) { secrets, err := ss.client.List(listOptions) if err != nil { @@ -72,7 +74,6 @@ func (ss SecretsService) List(listOptions metav1.ListOptions) ([]v1.Secret, erro return secrets.Items, nil } -// GetUnused returns unused resources func (ss SecretsService) GetUnused(namespace string, resources []v1.Secret) (unusedResources []v1.Secret, funcErr error) { var usedSecrets []v1.Secret funk.ForEach(openshift.PredefinedResources, func(predefinedResource schema.GroupVersionResource) { @@ -105,76 +106,52 @@ func (ss SecretsService) GetUnused(namespace string, resources []v1.Secret) (unu return unusedResources, funcErr } -// Delete removes secrets -func (ss SecretsService) Delete(secrets []v1.Secret) { +func (ss SecretsService) Delete(secrets []v1.Secret) error { for _, resource := range secrets { - namespace := resource.Namespace - name := resource.Name - + err := ss.client.Delete(resource.Name, &metav1.DeleteOptions{}) + if err != nil { + return err + } if ss.configuration.Batch { - fmt.Println(name) + fmt.Println(resource.Name) } else { - log.Infof("Deleting secret %s/%s", namespace, name) - } - - err := ss.client.Delete(name, &metav1.DeleteOptions{}) - - if err != nil { - log.WithError(err).Errorf("Failed to delete secret %s/%s", namespace, name) + log.Infof("Deleted Secret %s/%s", resource.Namespace, resource.Name) } } + return nil } -//FilterByTime returns secrets which are older than specified date func (ss SecretsService) FilterByTime(secrets []v1.Secret, olderThan time.Time) (filteredResources []v1.Secret) { log.WithFields(log.Fields{ "olderThan": olderThan, - }).Debug("Filtering resources older than the specified time") + }).Debug("Filtering resources older than the specified time.") for _, resource := range secrets { - lastUpdatedDate := resource.GetCreationTimestamp() - // In case the creation date is null (isZero()) treat as oldest - if lastUpdatedDate.IsZero() || lastUpdatedDate.Time.Before(olderThan) { + if util.IsOlderThan(&resource, olderThan) { filteredResources = append(filteredResources, resource) - log.WithFields(log.Fields{ - "secret": resource.Name, - }).Debug("Filtering resource") - } else { - log.WithField("name", resource.GetName()).Debug("Filtered resource") } } - return filteredResources } -// FilterByMaxCount keep at most n newest resources. The list of secrets is sorted in descending ordered in func (ss SecretsService) FilterByMaxCount(secrets []v1.Secret, keep int) (filteredResources []v1.Secret) { - log.WithFields(log.Fields{ - "keep": keep, - "secrets": secrets, - }).Debug("Filtering ordered by time Resources from the n'th number specified") + "keep": keep, + }).Debug("Filtering out oldest resources to a capped amount.") + + if len(secrets) <= keep { + return []v1.Secret{} + } sort.SliceStable(secrets, func(i, j int) bool { timestampFirst := secrets[j].GetCreationTimestamp() timestampSecond := secrets[i].GetCreationTimestamp() - if timestampFirst.IsZero() || timestampFirst.IsZero() && timestampSecond.IsZero() { - return true - } else if timestampSecond.IsZero() { - return false - } - return timestampSecond.Time.Before(timestampSecond.Time) + return util.CompareTimestamps(timestampFirst, timestampSecond) }) - if len(secrets) <= keep { - return []v1.Secret{} - } - return secrets[keep:] } -// Print prints the given resource line by line. In batch mode, only the resource is printed, otherwise default -// log with info level func (ss SecretsService) Print(resources []v1.Secret) { if len(resources) == 0 { log.Info("Nothing found to be deleted.") @@ -185,7 +162,7 @@ func (ss SecretsService) Print(resources []v1.Secret) { } } else { for _, resource := range resources { - log.Infof("Found candidate: %s/%s", resource.Namespace, resource.GetName()) + log.Infof("Found candidate: %s/%s", resource.Namespace, resource.Name) } } } diff --git a/pkg/secret/secret_test.go b/pkg/secret/secret_test.go index 289dde2..4ac86ba 100644 --- a/pkg/secret/secret_test.go +++ b/pkg/secret/secret_test.go @@ -2,13 +2,13 @@ package secret import ( "errors" + "github.com/appuio/seiso/pkg/kubernetes" "github.com/stretchr/testify/assert" "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes/fake" - core "k8s.io/client-go/kubernetes/typed/core/v1" test "k8s.io/client-go/testing" "testing" "time" @@ -17,7 +17,7 @@ import ( type HelperKubernetes struct{} type HelperKubernetesErr struct{} -func (k *HelperKubernetes) ResourceContains(namespace, value string, resource schema.GroupVersionResource) (bool, error) { +func (k HelperKubernetes) ResourceContains(namespace, value string, resource schema.GroupVersionResource) (bool, error) { if "nameA" == value { return false, nil } else { @@ -25,7 +25,7 @@ func (k *HelperKubernetes) ResourceContains(namespace, value string, resource sc } } -func (k *HelperKubernetesErr) ResourceContains(namespace, value string, resource schema.GroupVersionResource) (bool, error) { +func (k HelperKubernetesErr) ResourceContains(namespace, value string, resource schema.GroupVersionResource) (bool, error) { return false, errors.New("error") } @@ -34,27 +34,32 @@ var testNamespace = "testNamespace" func Test_PrintNamesAndLabels(t *testing.T) { tests := []struct { - name string - secrets []v1.Secret - err error + name string + secrets []v1.Secret + expectErr bool + reaction test.ReactionFunc }{ { - name: "GivenListOfSecrets_WhenListError_ThenReturnError", - secrets: []v1.Secret{}, - err: errors.New("error secret"), + name: "GivenListOfSecrets_WhenListError_ThenReturnError", + secrets: []v1.Secret{}, + reaction: createErrorReactor(), + expectErr: true, }, + // TODO: Add test case that asserts for correct lines printed } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - clientset := fake.NewSimpleClientset() - clientset.PrependReactor("list", "secrets", func(action test.Action) (handled bool, ret runtime.Object, err error) { - return true, &v1.SecretList{}, tt.err - }) - fakeSecretInterface := clientset.CoreV1().Secrets(testNamespace) - service := NewSecretsService(fakeSecretInterface, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) + clientset := fake.NewSimpleClientset(convertToRuntime(tt.secrets)[:]...) + clientset.PrependReactor("list", "secrets", tt.reaction) + fakeClient := clientset.CoreV1().Secrets(testNamespace) + service := NewSecretsService(fakeClient, &HelperKubernetes{}, ServiceConfiguration{}) err := service.PrintNamesAndLabels(testNamespace) - assert.EqualError(t, err, tt.err.Error()) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } }) } } @@ -62,9 +67,10 @@ func Test_PrintNamesAndLabels(t *testing.T) { func Test_List(t *testing.T) { tests := []struct { - name string - secrets []v1.Secret - err error + name string + secrets []v1.Secret + reaction test.ReactionFunc + expectErr bool }{ { name: "GivenListOfSecrets_WhenAllPresent_ThenReturnAllOfThem", @@ -75,34 +81,29 @@ func Test_List(t *testing.T) { secrets: []v1.Secret{}, }, { - name: "GivenListOfSecrets_WhenListError_ThenReturnError", - secrets: []v1.Secret{}, - err: errors.New("error secret"), + name: "GivenListOfSecrets_WhenListError_ThenReturnError", + secrets: []v1.Secret{}, + reaction: createErrorReactor(), + expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var fakeSecretInterface core.SecretInterface - if len(tt.secrets) != 0 { - fakeSecretInterface = fake.NewSimpleClientset(&tt.secrets[0], &tt.secrets[1]).CoreV1().Secrets(testNamespace) - } else { - clientset := fake.NewSimpleClientset() - clientset.PrependReactor("list", "secrets", func(action test.Action) (handled bool, ret runtime.Object, err error) { - return true, &v1.SecretList{}, tt.err - }) - fakeSecretInterface = clientset.CoreV1().Secrets(testNamespace) + clientset := fake.NewSimpleClientset(convertToRuntime(tt.secrets)[:]...) + if tt.reaction != nil { + clientset.PrependReactor("list", "secrets", tt.reaction) } - - service := NewSecretsService(fakeSecretInterface, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) + fakeClient := clientset.CoreV1().Secrets(testNamespace) + service := NewSecretsService(fakeClient, &HelperKubernetes{}, ServiceConfiguration{}) list, err := service.List(metav1.ListOptions{}) - if tt.err == nil { - assert.NoError(t, err) - assert.ElementsMatch(t, tt.secrets, list) - } else { - assert.EqualError(t, err, tt.err.Error()) + if tt.expectErr { + assert.Error(t, err) + return } + assert.NoError(t, err) + assert.ElementsMatch(t, tt.secrets, list) }) } } @@ -114,27 +115,17 @@ func Test_FilterByTime(t *testing.T) { secrets []v1.Secret filteredSecrets []v1.Secret cutOffDate time.Time - err error }{ { - name: "GivenListOfSecrets_WhenFilteredByTime_ThenReturnASubsetOfSecrets", + name: "GivenListOfSecrets_WhenOlder_ThenReturnASubsetOfSecrets", secrets: generateBaseTestSecrets(), filteredSecrets: []v1.Secret{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "nameB", - Namespace: testNamespace, - CreationTimestamp: metav1.Time{ - Time: time.Date(2010, 1, 1, 1, 0, 0, 0, time.UTC), - }, - Labels: map[string]string{"keyB": "valueB", "keyC": "valueC"}, - }, - }, + generateBaseTestSecrets()[1], }, cutOffDate: time.Date(2015, 1, 1, 1, 0, 0, 0, time.UTC), }, { - name: "GivenListOfSecrets_WhenFilteredBefore2010_ThenReturnEmptyList", + name: "GivenListOfSecrets_WhenNewer_ThenReturnEmptyList", secrets: generateBaseTestSecrets(), filteredSecrets: []v1.Secret{}, cutOffDate: time.Date(2005, 1, 1, 1, 0, 0, 0, time.UTC), @@ -142,8 +133,8 @@ func Test_FilterByTime(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - fakeSecretInterface := fake.NewSimpleClientset(&tt.secrets[0], &tt.secrets[1]).CoreV1().Secrets(testNamespace) - service := NewSecretsService(fakeSecretInterface, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) + fakeClient := fake.NewSimpleClientset(convertToRuntime(tt.secrets)[:]...).CoreV1().Secrets(testNamespace) + service := NewSecretsService(fakeClient, &HelperKubernetes{}, ServiceConfiguration{}) filteredSecrets := service.FilterByTime(tt.secrets, tt.cutOffDate) assert.ElementsMatch(t, filteredSecrets, tt.filteredSecrets) }) @@ -157,24 +148,12 @@ func Test_FilterByMaxCount(t *testing.T) { secrets []v1.Secret filteredSecrets []v1.Secret keep int - err error }{ { - name: "GivenListOfSecrets_FilterByMaxCountOne_ThenReturnOneSecret", - secrets: generateBaseTestSecrets(), - filteredSecrets: []v1.Secret{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "nameB", - Namespace: testNamespace, - CreationTimestamp: metav1.Time{ - Time: time.Date(2010, 1, 1, 1, 0, 0, 0, time.UTC), - }, - Labels: map[string]string{"keyB": "valueB", "keyC": "valueC"}, - }, - }, - }, - keep: 1, + name: "GivenListOfSecrets_FilterByMaxCountOne_ThenReturnOneSecret", + secrets: generateBaseTestSecrets(), + filteredSecrets: []v1.Secret{generateBaseTestSecrets()[1]}, + keep: 1, }, { name: "GivenListOfSecrets_FilterByMaxCountZero_ThenReturnTwoSecrets", @@ -191,8 +170,8 @@ func Test_FilterByMaxCount(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - fakeSecretInterface := fake.NewSimpleClientset(&tt.secrets[0], &tt.secrets[1]).CoreV1().Secrets(testNamespace) - service := NewSecretsService(fakeSecretInterface, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) + fakeClient := fake.NewSimpleClientset(convertToRuntime(tt.secrets)[:]...).CoreV1().Secrets(testNamespace) + service := NewSecretsService(fakeClient, &HelperKubernetes{}, ServiceConfiguration{}) filteredSecrets := service.FilterByMaxCount(tt.secrets, tt.keep) assert.ElementsMatch(t, filteredSecrets, tt.filteredSecrets) }) @@ -201,47 +180,41 @@ func Test_FilterByMaxCount(t *testing.T) { func Test_Delete(t *testing.T) { tests := []struct { - name string - secrets []v1.Secret - remained int - err error + name string + secrets []v1.Secret + expectedRemaining []v1.Secret + reaction test.ReactionFunc + expectErr bool }{ { - name: "GivenASetOfSecrets_WhenAllPresent_ThenDeleteAllOfThem", - secrets: generateBaseTestSecrets(), - remained: 0, + name: "GivenSetOfSecrets_WhenAllPresent_ThenDeleteAllOfThem", + secrets: generateBaseTestSecrets(), + expectedRemaining: []v1.Secret{}, }, { - name: "GivenASetOfSecrets_WhenError_ThenReturnError", - secrets: generateBaseTestSecrets(), - remained: 2, - err: errors.New("secret error"), + name: "GivenSetOfSecrets_WhenDeletionError_ThenReturnError", + secrets: generateBaseTestSecrets(), + expectedRemaining: generateBaseTestSecrets(), + reaction: createErrorReactor(), + expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var fakeSecretInterface core.SecretInterface - if tt.err == nil { - fakeSecretInterface = fake.NewSimpleClientset(&tt.secrets[0], &tt.secrets[1]).CoreV1().Secrets(testNamespace) - } else { - clientset := fake.NewSimpleClientset(&tt.secrets[0], &tt.secrets[1]) - clientset.PrependReactor("delete", "secrets", func(action test.Action) (handled bool, ret runtime.Object, err error) { - return true, nil, tt.err - }) - fakeSecretInterface = clientset.CoreV1().Secrets(testNamespace) + clientset := fake.NewSimpleClientset(convertToRuntime(tt.secrets)[:]...) + if tt.reaction != nil { + clientset.PrependReactor("delete", "secrets", tt.reaction) } - service := NewSecretsService(fakeSecretInterface, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) - service.Delete(tt.secrets) - list, err := fakeSecretInterface.List(metav1.ListOptions{}) - - assert.NoError(t, err) - if tt.err == nil { - assert.EqualValues(t, tt.remained, len(list.Items)) - } else { - assert.EqualValues(t, tt.remained, len(list.Items)) + fakeClient := clientset.CoreV1().Secrets(testNamespace) + service := NewSecretsService(fakeClient, &HelperKubernetes{}, ServiceConfiguration{}) + err := service.Delete(tt.secrets) + if tt.expectErr { + assert.Error(t, err) } - + list, err := fakeClient.List(metav1.ListOptions{}) + assert.NoError(t, err) + assert.ElementsMatch(t, tt.expectedRemaining, list.Items) }) } } @@ -251,45 +224,35 @@ func Test_GetUnused(t *testing.T) { name string allSecrets []v1.Secret unusedSecrets []v1.Secret - err error + expectErr bool }{ { - name: "GivenASetOfSecrets_WhenOneSecretIsUsed_ThenFilterItOut", - allSecrets: generateBaseTestSecrets(), - unusedSecrets: []v1.Secret{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "nameA", - Namespace: testNamespace, - CreationTimestamp: metav1.Time{ - Time: time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC), - }, - Labels: map[string]string{"keyA": "valueA"}, - }, - }, - }, + name: "GivenASetOfSecrets_WhenOneSecretIsUsed_ThenFilterItOut", + allSecrets: generateBaseTestSecrets(), + unusedSecrets: []v1.Secret{generateBaseTestSecrets()[0]}, }, { name: "GivenASetOfSecrets_WhenError_ThenReturnError", allSecrets: generateBaseTestSecrets(), unusedSecrets: generateBaseTestSecrets(), - err: errors.New("error"), + expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.err == nil { - service := NewSecretsService(nil, &HelperKubernetes{}, ServiceConfiguration{Batch: false}) - unused, err := service.GetUnused(testNamespace, tt.allSecrets) - assert.NoError(t, err) - assert.ElementsMatch(t, tt.unusedSecrets, unused) - } else { - service := NewSecretsService(nil, &HelperKubernetesErr{}, ServiceConfiguration{Batch: false}) - unused, err := service.GetUnused(testNamespace, tt.allSecrets) + var helper kubernetes.Kubernetes = HelperKubernetes{} + if tt.expectErr { + helper = HelperKubernetesErr{} + } + service := NewSecretsService(nil, helper, ServiceConfiguration{}) + unused, err := service.GetUnused(testNamespace, tt.allSecrets) + if tt.expectErr { assert.Error(t, err) - assert.ElementsMatch(t, tt.unusedSecrets, unused) + return } + assert.NoError(t, err) + assert.ElementsMatch(t, tt.unusedSecrets, unused) }) } } @@ -318,3 +281,16 @@ func generateBaseTestSecrets() []v1.Secret { }, } } + +func convertToRuntime(secrets []v1.Secret) (objects []runtime.Object) { + for _, s := range secrets { + objects = append(objects, s.DeepCopyObject()) + } + return objects +} + +func createErrorReactor() test.ReactionFunc { + return func(action test.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, errors.New("error") + } +} diff --git a/pkg/util/common.go b/pkg/util/common.go index 0c8d074..43e75f5 100644 --- a/pkg/util/common.go +++ b/pkg/util/common.go @@ -1,22 +1,42 @@ package util import ( - "github.com/thoas/go-funk" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sort" + "strings" + "time" ) -func GetNamesAndLabels(resources []metav1.ObjectMeta) map[string][]string { - resourceNamesWithLabels := make(map[string][]string) - for _, resource := range resources { - var labels []string - for key, element := range resource.Labels { - label := key + "=" + element - if !funk.ContainsString(labels, label) { - labels = append(labels, label) - } - } - resourceNamesWithLabels[resource.Name] = labels +// FlattenStringMap turns a map of strings into a single string in the format of "[key1=value, key2=value]" +func FlattenStringMap(m map[string]string) string { + // Map keys are by design unordered, so we create an array of keys, sort them, then join together alphabetically. + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) } + sort.Strings(keys) + pairs := make([]string, 0, len(m)) + for _, k := range keys { + pairs = append(pairs, k + "=" + m[k]) + } + return "[" + strings.Join(pairs, ", ") + "]" +} + +// IsOlderThan returns true if the given resource is older than the specified timestamp. If the resource does not have +// a timestamp or is zero, it returns true. +func IsOlderThan(resource metav1.Object, olderThan time.Time) bool { + lastUpdatedDate := resource.GetCreationTimestamp() + return lastUpdatedDate.IsZero() || lastUpdatedDate.Time.Before(olderThan) +} - return resourceNamesWithLabels +// CompareTimestamps compares whether the first timestamp is newer than the second. If both timestamps share the same +// time down to the second, the nano second will be compared. If the time is zero, it will be treated as older than the other. +func CompareTimestamps(first, second metav1.Time) bool { + if first.IsZero() { + return false + } + if second.IsZero() { + return true + } + return first.Time.Before(second.Time) } diff --git a/pkg/util/common_test.go b/pkg/util/common_test.go index 4877b71..4fafa26 100644 --- a/pkg/util/common_test.go +++ b/pkg/util/common_test.go @@ -2,63 +2,135 @@ package util import ( "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "testing" + "time" ) -func Test_PrintNamesAndLabels(t *testing.T) { +func TestFlattenStringMap(t *testing.T) { + tests := []struct { + name string + labels map[string]string + expected string + }{ + { + name: "GivenStringMap_WhenSingleEntry_ThenReturnSingleString", + labels: map[string]string{"key": "value"}, + expected: "[key=value]", + }, + { + name: "GivenStringMap_WhenMultipleEntries_ThenReturnMultipleStringsWithinBrackets", + labels: map[string]string{"key1": "value", "key2": "value2"}, + expected: "[key1=value, key2=value2]", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FlattenStringMap(tt.labels) + assert.Equal(t, tt.expected, result) + }) + } +} +func TestCompareTimestamps(t *testing.T) { tests := []struct { - name string - resources []metav1.ObjectMeta - resourceNamesWithLabels map[string][]string + name string + first metav1.Time + second metav1.Time + expectedResult bool }{ { - name: "GivenListOfObjectMetas_WhenNamesAndLabelsDefined_ThenReturnAMapOfNamesAndLabels", - resources: generateObjectMetas(), - resourceNamesWithLabels: map[string][]string{ - "NameA": { - "LabelKeyA=LabelValueA", - "LabelKeyB=LabelValueB", - "LabelKeyC=LabelValueC", - }, - "NameB": nil, - "NameC": {"LabelKeyD=LabelValueD"}, - }, + name: "GivenDifferentTimestamps_WhenFirstIsNewer_ThenReturnTrue", + first: parseTime("2000-01-01T12:00:00Z"), + second: parseTime("2000-01-01T13:00:00Z"), + expectedResult: true, + }, + { + name: "GivenDifferentTimestamps_WhenSecondIsNewer_ThenReturnFalse", + first: parseTime("2000-01-01T13:00:00Z"), + second: parseTime("2000-01-01T12:00:00Z"), + expectedResult: false, + }, + { + name: "GivenSameTimestamps_WhenBothAreEqual_ThenReturnFalse", + first: parseTime("2000-01-01T12:00:00Z"), + second: parseTime("2000-01-01T12:00:00Z"), + expectedResult: false, }, { - name: "GivenEmptyListOfObjectMetas_ThenReturnAnEmptyMapOfNamesAndLabels", - resources: []metav1.ObjectMeta{}, - resourceNamesWithLabels: map[string][]string{}, + name: "GivenZeroTimestamps_WhenFirstIsZero_ThenReturnFalse", + first: zero(), + second: parseTime("2000-01-01T12:00:00Z"), + expectedResult: false, + }, + { + name: "GivenZeroTimestamps_WhenSecondIsZero_ThenReturnTrue", + first: parseTime("2000-01-01T12:00:00Z"), + second: zero(), + expectedResult: true, + }, + { + name: "GivenZeroTimestamps_WhenBothAreZero_ThenReturnFalse", + first: zero(), + second: zero(), + expectedResult: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - namesAndLabels := GetNamesAndLabels(tt.resources) - assert.Equal(t, namesAndLabels, tt.resourceNamesWithLabels) + result := CompareTimestamps(tt.first, tt.second) + assert.Equal(t, tt.expectedResult, result) }) } } -func generateObjectMetas() []metav1.ObjectMeta { - return []metav1.ObjectMeta{ +func TestIsOlderThan(t *testing.T) { + tests := []struct { + name string + resource metav1.Object + olderThan time.Time + expectedResult bool + }{ { - Name: "NameA", - Labels: map[string]string{ - "LabelKeyA": "LabelValueA", - "LabelKeyB": "LabelValueB", - "LabelKeyC": "LabelValueC", - }, + name: "GivenResourceWithTimestamp_WhenNewer_ThenReturnFalse", + resource: &v1.ConfigMap{ObjectMeta: metav1.ObjectMeta{CreationTimestamp: parseTime("2000-01-01T13:00:00Z")}}, + olderThan: parseTime("2000-01-01T12:00:00Z").Time, + expectedResult: false, }, { - Name: "NameB", - Labels: map[string]string{}, + name: "GivenResourceWithTimestamp_WhenOlder_ThenReturnTrue", + resource: &v1.ConfigMap{ObjectMeta: metav1.ObjectMeta{CreationTimestamp: parseTime("2000-01-01T12:00:00Z")}}, + olderThan: parseTime("2000-01-01T13:00:00Z").Time, + expectedResult: true, }, { - Name: "NameC", - Labels: map[string]string{ - "LabelKeyD": "LabelValueD", - }, + name: "GivenResourceWithoutTimestamp_WhenZero_ThenReturnTrue", + resource: &v1.ConfigMap{}, + olderThan: parseTime("2000-01-01T13:00:00Z").Time, + expectedResult: true, }, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsOlderThan(tt.resource, tt.olderThan) + assert.Equal(t, tt.expectedResult, result) + }) + } +} + +func parseTime(stamp string) metav1.Time { + t, err := time.Parse(time.RFC3339, stamp) + if err != nil { + panic(err) + } + return metav1.Time{ + Time: t, + } +} + +func zero() metav1.Time { + return metav1.Time{ + Time: time.Time{}, + } }