diff --git a/go.mod b/go.mod index 837eb82..34afd7d 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.17 require ( github.com/go-logr/logr v1.2.0 github.com/onsi/ginkgo v1.16.5 - github.com/onsi/gomega v1.17.0 + github.com/onsi/gomega v1.19.0 github.com/operator-framework/api v0.10.0 github.com/prometheus/client_golang v1.11.0 github.com/prometheus/client_model v0.2.0 @@ -38,10 +38,10 @@ require ( github.com/prometheus/common v0.28.0 // indirect github.com/prometheus/procfs v0.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/net v0.0.0-20210825183410-e898025ed96a // indirect + golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f // indirect - golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8 // indirect - golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect diff --git a/go.sum b/go.sum index 264e0b8..070a021 100644 --- a/go.sum +++ b/go.sum @@ -417,6 +417,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= github.com/onsi/gomega v1.17.0 h1:9Luw4uT5HTjHTN8+aNcSThgH1vdXnmdJ8xIfZ4wyTRE= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/operator-framework/api v0.10.0 h1:TaxbgbrV8D3wnKNyrImZ2zjQVVHMQRc7piWLDmlGoEE= github.com/operator-framework/api v0.10.0/go.mod h1:tV0BUNvly7szq28ZPBXhjp1Sqg5yHCOeX19ui9K4vjI= @@ -667,6 +669,8 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210825183410-e898025ed96a h1:bRuuGXV8wwSdGTB+CtJf+FjgO1APK1CoO39T4BN/XBw= golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -766,11 +770,15 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8 h1:M69LAlWZCshgp0QSzyDcSsSIejIEeuaCVpmwcKwyLMk= golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/prune/maxage.go b/prune/maxage.go deleted file mode 100644 index 4c47d84..0000000 --- a/prune/maxage.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2021 The Operator-SDK Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package prune - -import ( - "context" - "time" -) - -// maxAge looks for and prunes resources, currently jobs and pods, -// that exceed a user specified age (e.g. 3d), resources to be removed -// are returned -func pruneByMaxAge(ctx context.Context, config Config, resources []ResourceInfo) (resourcesToRemove []ResourceInfo, err error) { - log := Logger(ctx, config) - log.V(1).Info("maxAge running", "setting", config.Strategy.MaxAgeSetting) - - maxAgeDuration, e := time.ParseDuration(config.Strategy.MaxAgeSetting) - if e != nil { - return resourcesToRemove, e - } - - maxAgeTime := time.Now().Add(-maxAgeDuration) - - for i := 0; i < len(resources); i++ { - log.V(1).Info("age of pod ", "age", time.Since(resources[i].StartTime), "maxage", maxAgeTime) - if resources[i].StartTime.Before(maxAgeTime) { - log.V(1).Info("pruning ", "kind", resources[i].GVK, "name", resources[i].Name) - - resourcesToRemove = append(resourcesToRemove, resources[i]) - } - } - - return resourcesToRemove, nil -} diff --git a/prune/maxcount.go b/prune/maxcount.go deleted file mode 100644 index 303d219..0000000 --- a/prune/maxcount.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2021 The Operator-SDK Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package prune - -import ( - "context" - "fmt" - "time" -) - -// pruneByMaxCount looks for and prunes resources, currently jobs and pods, -// that exceed a user specified count (e.g. 3), the oldest resources -// are pruned, resources to remove are returned -func pruneByMaxCount(ctx context.Context, config Config, resources []ResourceInfo) (resourcesToRemove []ResourceInfo, err error) { - log := Logger(ctx, config) - log.V(1).Info("pruneByMaxCount running ", "max count", config.Strategy.MaxCountSetting, "resource count", len(resources)) - if config.Strategy.MaxCountSetting < 0 { - return resourcesToRemove, fmt.Errorf("max count setting less than zero") - } - - if len(resources) > config.Strategy.MaxCountSetting { - removeCount := len(resources) - config.Strategy.MaxCountSetting - for i := len(resources) - 1; i >= 0; i-- { - log.V(1).Info("pruning pod ", "pod name", resources[i].Name, "age", time.Since(resources[i].StartTime)) - - resourcesToRemove = append(resourcesToRemove, resources[i]) - - removeCount-- - if removeCount == 0 { - break - } - } - } - - return resourcesToRemove, nil -} diff --git a/prune/prunables.go b/prune/prunables.go new file mode 100644 index 0000000..d4f07c3 --- /dev/null +++ b/prune/prunables.go @@ -0,0 +1,104 @@ +// Copyright 2021 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prune + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" +) + +// Some default pruneable functions + +// DefaultPodIsPruneable is a default IsPruneableFunc to be used specifically with Pod resources. +// This can be overridden by registering your own IsPruneableFunc via the RegisterIsPruneableFunc method +func DefaultPodIsPruneable(obj client.Object) error { + gvk := schema.GroupVersionKind{ + Group: "core", + Version: "v1", + Kind: "Pod", + } + + // if the object is not a Pod then it is not pruneable + if obj.GetObjectKind().GroupVersionKind() != gvk { + return fmt.Errorf("can not prune object as it is not a Pod") + } + + // convert to a Pod object so we can do Pod specific checks + pod := &corev1.Pod{} + err := ConvertClientObjectToKind(obj, pod) + + if err != nil { + return err + } + + // If the pod has Succeeded then we can remove it + if pod.Status.Phase != corev1.PodSucceeded { + return fmt.Errorf("can not prune Pod as it has not succeeded") + } + + return nil +} + +// DefaultJobIsPruneable is a default IsPruneableFunc to be used specifically with Job resources. +// This can be overridden by registering your own IsPruneableFunc via the RegisterIsPruneableFunc method +func DefaultJobIsPruneable(obj client.Object) error { + + gvk := schema.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + } + + if obj.GetObjectKind().GroupVersionKind() != gvk { + return fmt.Errorf("can not prune object as it is not a Job") + } + + job := &batchv1.Job{} + err := ConvertClientObjectToKind(obj, job) + + if err != nil { + return err + } + + // If the job has completed we can remove it + if job.Status.CompletionTime == nil { + return fmt.Errorf("can not prune Job as it has not completed") + } + + return nil +} + +// ConvertClientObjectToKind is a helper function to convert from a client.Object to a specified interface{} +func ConvertClientObjectToKind(object client.Object, kind interface{}) error { + unstructuredObject, err := runtime.DefaultUnstructuredConverter.ToUnstructured(object) + + if err != nil { + return fmt.Errorf("failed to convert object to Unstructured -- %s", err) + } + + err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObject, kind) + + if err != nil { + return fmt.Errorf("failed to convert object to kind -- %s", err) + } + + return nil +} diff --git a/prune/prune.go b/prune/prune.go index 62133cd..b987929 100644 --- a/prune/prune.go +++ b/prune/prune.go @@ -17,181 +17,188 @@ package prune import ( "context" "fmt" - "time" "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "sigs.k8s.io/controller-runtime/pkg/client" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" ) -// ResourceStatus describes the Kubernetes resource status we are evaluating -type ResourceStatus string - -// Strategy describes the pruning strategy we want to employ -type Strategy string - -const ( - // CustomStrategy maximum age of a resource that is desired, Duration - CustomStrategy Strategy = "Custom" - // MaxAgeStrategy maximum age of a resource that is desired, Duration - MaxAgeStrategy Strategy = "MaxAge" - // MaxCountStrategy maximum number of a resource that is desired, int - MaxCountStrategy Strategy = "MaxCount" - // JobKind equates to a Kube Job resource kind - JobKind string = "Job" - // PodKind equates to a Kube Pod resource kind - PodKind string = "Pod" -) +/* + ------------------------------------------- + New Auto Pruning API Implementation + ------------------------------------------- +*/ + +// Pruner is an object that runs a prune job. +type Pruner struct { + // Registry is the IsPruneableFunc registry + Registry *Registry + + // Client is the k8s client that will be used + Client client.Client + + // DryRun indicates whether or not we should actually perform pruning or just return the list of pruneable objects + // true = Just check, don't prune + // false (default) = Prune + DryRun bool + + // Strategy is the function used to determine a list of resources that are pruneable + Strategy StrategyFunc -// StrategyConfig holds settings unique to each pruning mode -type StrategyConfig struct { - Mode Strategy - MaxAgeSetting string - MaxCountSetting int - CustomSettings map[string]interface{} + // Labels is a map of the labels to use for label matching when looking for resources + Labels map[string]string + + // Namespace is the namespace to use when looking for resources + Namespace string + + // Logger is the logger to use when running pruning functionality + Logger logr.Logger } -// StrategyFunc function allows a means to specify -// custom prune strategies -type StrategyFunc func(ctx context.Context, cfg Config, resources []ResourceInfo) ([]ResourceInfo, error) - -// PreDelete function is called before a resource is pruned -type PreDelete func(ctx context.Context, cfg Config, something ResourceInfo) error - -// Config defines a pruning configuration and ultimately -// determines what will get pruned -type Config struct { - Clientset kubernetes.Interface // kube client used by pruning - LabelSelector string //selector resources to prune - DryRun bool //true only performs a check, not removals - Resources []schema.GroupVersionKind //pods, jobs are supported - Namespaces []string //empty means all namespaces - Strategy StrategyConfig //strategy for pruning, either age or max - CustomStrategy StrategyFunc //custom strategy - PreDeleteHook PreDelete //called before resource is deleteds - Log logr.Logger //optional: to overwrite the logger set at context level +// ErrUnpruneable indicates that it is not allowed to prune a specific object. +type ErrUnpruneable struct { + Obj *client.Object + Reason string } -// Execute causes the pruning work to be executed based on its configuration -func (config Config) Execute(ctx context.Context) error { - log := Logger(ctx, config) - log.V(1).Info("Execute Prune") +// StrategyFunc takes a list of resources and returns the subset to prune. +type StrategyFunc func(ctx context.Context, objs []client.Object) ([]client.Object, error) + +// IsPruneableFunc is a function that checks a the data of an object to see whether or not it is safe to prune it. +// It should return `nil` if it is safe to prune, `ErrUnpruneable` if it is unsafe, or another error. +// It should safely assert the object is the expected type, otherwise it might panic. +type IsPruneableFunc func(obj client.Object) error + +// PrunerOption configures the pruner. +type PrunerOption func(p *Pruner) + +// Error returns a string reprenstation of an `ErrUnpruneable` error. +func (e *ErrUnpruneable) Error() string { + return fmt.Sprintf("%s -- %+v", e.Reason, e.Obj) +} - err := config.validate() +// NewPruner returns a pruner that uses the given strategy to prune objects. +func NewPruner(config *rest.Config, opts ...PrunerOption) (Pruner, error) { + prunerClient, err := client.New(config, client.Options{}) if err != nil { - return err + return Pruner{}, err } - for i := 0; i < len(config.Resources); i++ { - var resourceList []ResourceInfo - var err error + pruner := Pruner{ + Registry: &defaultRegistry, + Client: prunerClient, + DryRun: false, + Logger: Logger(context.Background(), Pruner{}), + } - if config.Resources[i].Kind == PodKind { - resourceList, err = config.getSucceededPods(ctx) - if err != nil { - return err - } - log.V(1).Info("pods ", "count", len(resourceList)) - } else if config.Resources[i].Kind == JobKind { - resourceList, err = config.getCompletedJobs(ctx) - if err != nil { - return err - } - log.V(1).Info("jobs ", "count", len(resourceList)) - } + // Populate the default IsPruneableFunc(s) + RegisterIsPrunableFunc(schema.GroupVersionKind{ + Group: "core", + Version: "v1", + Kind: "Pod", + }, DefaultPodIsPruneable) + + RegisterIsPrunableFunc(schema.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + }, DefaultJobIsPruneable) + + for _, opt := range opts { + opt(&pruner) + } - var resourcesToRemove []ResourceInfo - - switch config.Strategy.Mode { - case MaxAgeStrategy: - resourcesToRemove, err = pruneByMaxAge(ctx, config, resourceList) - case MaxCountStrategy: - resourcesToRemove, err = pruneByMaxCount(ctx, config, resourceList) - case CustomStrategy: - resourcesToRemove, err = config.CustomStrategy(ctx, config, resourceList) - default: - return fmt.Errorf("unknown strategy") - } - if err != nil { - return err + return pruner, nil +} + +// Prune runs the pruner. +func (p Pruner) Prune(ctx context.Context) ([]client.Object, error) { + // Process of pruning would be: + // 1. Get the list of resources based on Pruner configuration + // 2. Run the Strategy function + // 3. Prune resources returned from strategy function + var prunedObjects []client.Object + + // Need to loop based on the registered IsPrunableFuncs and their GVKs + for gvk, isPrunable := range p.Registry.Pruneables { + // Get list of resources + listOpts := client.ListOptions{ + LabelSelector: labels.Set(p.Labels).AsSelector(), + Namespace: p.Namespace, } - err = config.removeResources(ctx, resourcesToRemove) + unstructuredObjects := &unstructured.UnstructuredList{} + unstructuredObjects.SetAPIVersion(gvk.GroupVersion().String()) + unstructuredObjects.SetKind(gvk.Kind) + + err := p.Client.List(ctx, unstructuredObjects, &listOpts) + if err != nil { - return err + return nil, fmt.Errorf("failed to get list of objects -- ERROR -- %s", err) } - } - log.V(1).Info("Prune completed") + // Run strategy function - return nil -} + var objects []client.Object -// containsString checks if a string is present in a slice -func containsString(s []string, str string) bool { - for _, v := range s { - if v == str { - return true + for i := range unstructuredObjects.Items { + // reference the original object + objects = append(objects, &unstructuredObjects.Items[i]) } - } - return false -} + objectsToPrune, err := p.Strategy(ctx, objects) -// containsName checks if a string is present in a ResourceInfo slice -func containsName(s []ResourceInfo, str string) bool { - for _, v := range s { - if v.Name == str { - return true + if err != nil { + return nil, fmt.Errorf("failed when running Strategy -- ERROR -- %s", err) } - } - return false -} -func (config Config) validate() (err error) { + // Prune the resources + for _, object := range objectsToPrune { + err = isPrunable(object) - if config.CustomStrategy == nil && config.Strategy.Mode == CustomStrategy { - return fmt.Errorf("custom strategies require a strategy function to be specified") - } + if err != nil { + // Not prunable so skip it + continue + } - if len(config.Namespaces) == 0 { - return fmt.Errorf("namespaces are required") - } + // Prune + if !p.DryRun { + err = p.Client.Delete(ctx, object) - if containsString(config.Namespaces, "") { - return fmt.Errorf("empty namespace value not supported") - } + if err != nil { + return prunedObjects, fmt.Errorf("failed to prune object -- ERROR -- %s", err) + } + } - _, err = labels.Parse(config.LabelSelector) - if err != nil { - return err - } + prunedObjects = append(prunedObjects, object) - if config.Strategy.Mode == MaxAgeStrategy { - _, err = time.ParseDuration(config.Strategy.MaxAgeSetting) - if err != nil { - return err - } - } - if config.Strategy.Mode == MaxCountStrategy { - if config.Strategy.MaxCountSetting < 0 { - return fmt.Errorf("max count is required to be greater than or equal to 0") } + } - return nil + + return prunedObjects, nil } +/* + ------------------------------------------- + New Auto Pruning API Implementation + ------------------------------------------- +*/ + // Logger returns a logger from the context using logr method or Config.Log if none is found // controller-runtime automatically provides a logger in context.Context during Reconcile calls. // Note that there is no compile time check whether a logger can be retrieved by either way. // keysAndValues allow to add fields to the logs, cf logr documentation. -func Logger(ctx context.Context, cfg Config, keysAndValues ...interface{}) logr.Logger { +func Logger(ctx context.Context, pruner Pruner, keysAndValues ...interface{}) logr.Logger { var log logr.Logger - if cfg.Log != (logr.Logger{}) { - log = cfg.Log + if pruner.Logger != (logr.Logger{}) { + log = pruner.Logger } else { log = ctrllog.FromContext(ctx) } diff --git a/prune/prune_test.go b/prune/prune_test.go new file mode 100644 index 0000000..e0cd983 --- /dev/null +++ b/prune/prune_test.go @@ -0,0 +1,521 @@ +// Copyright 2021 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prune + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + crFake "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var _ = Describe("Prune", func() { + var ( + fakeClient client.Client + fakeObj client.Object + prunerConfig PrunerOption + podGVK schema.GroupVersionKind + jobGVK schema.GroupVersionKind + ) + BeforeEach(func() { + fakeClient = crFake.NewClientBuilder().Build() + + fakeObj = &v1.Pod{} + + // Create our function to configure our pruner + prunerConfig = func(p *Pruner) { + + // Create the labels we want to select with + labels := make(map[string]string) + labels["app"] = "churro" + + // Set our strategy + p.Strategy = myStrategy + + p.Labels = labels + + p.Namespace = "default" + p.Client = fakeClient + } + + podGVK = schema.GroupVersionKind{ + Group: "core", + Version: "v1", + Kind: "Pod", + } + + jobGVK = schema.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + } + + }) + + Context("Pruner", func() { + It("Should Return a New Pruner Object", func() { + pruner, err := NewPruner(&rest.Config{}) + + Expect(err).Should(BeNil()) + Expect(pruner).ShouldNot(BeNil()) + }) + + It("Should Return a String Representation of ErrUnpruneable", func() { + unpruneable := ErrUnpruneable{ + Obj: &fakeObj, + Reason: "TestReason", + } + + Expect(unpruneable.Error()).To(Equal(fmt.Sprintf("%s -- %+v", unpruneable.Reason, unpruneable.Obj))) + }) + + It("Should Prune Pods with Default IsPruneableFunc", func() { + // Create the test resources - in this case Pods + err := createTestPods(fakeClient) + + Expect(err).Should(BeNil()) + + // Make sure the pod resources are properly created + pods := &unstructured.UnstructuredList{} + pods.SetGroupVersionKind(podGVK) + err = fakeClient.List(context.Background(), pods) + + Expect(err).Should(BeNil()) + Expect(len(pods.Items)).Should(Equal(3)) + + pruner, err := NewPruner(&rest.Config{}, prunerConfig) + + Expect(err).Should(BeNil()) + Expect(pruner).ShouldNot(BeNil()) + + prunedObjects, err := pruner.Prune(context.Background()) + + Expect(err).Should(BeNil()) + Expect(len(prunedObjects)).Should(Equal(2)) + + // Get a list of the Pods to make sure we have pruned the ones we expected + err = fakeClient.List(context.Background(), pods) + + Expect(err).Should(BeNil()) + Expect(len(pods.Items)).Should(Equal(1)) + }) + + It("Should Prune Jobs with Default IsPruneableFunc", func() { + // Create the test resources - in this case Jobs + err := createTestJobs(fakeClient) + + Expect(err).Should(BeNil()) + + // Make sure the job resources are properly created + jobs := &unstructured.UnstructuredList{} + jobs.SetGroupVersionKind(jobGVK) + err = fakeClient.List(context.Background(), jobs) + + Expect(err).Should(BeNil()) + Expect(len(jobs.Items)).Should(Equal(3)) + + pruner, err := NewPruner(&rest.Config{}, prunerConfig) + + Expect(err).Should(BeNil()) + Expect(pruner).ShouldNot(BeNil()) + + prunedObjects, err := pruner.Prune(context.Background()) + + Expect(err).Should(BeNil()) + Expect(len(prunedObjects)).Should(Equal(2)) + + // Get a list of the job to make sure we have pruned the ones we expected + err = fakeClient.List(context.Background(), jobs) + + Expect(err).Should(BeNil()) + Expect(len(jobs.Items)).Should(Equal(1)) + }) + + It("Should Remove Resource When Using a Custom IsPrunableFunc", func() { + // Create the test resources - in this case Jobs + err := createTestJobs(fakeClient) + + Expect(err).Should(BeNil()) + + // Make sure the job resources are properly created + jobs := &unstructured.UnstructuredList{} + jobs.SetGroupVersionKind(jobGVK) + err = fakeClient.List(context.Background(), jobs) + + Expect(err).Should(BeNil()) + Expect(len(jobs.Items)).Should(Equal(3)) + + pruner, err := NewPruner(&rest.Config{}, prunerConfig) + + Expect(err).Should(BeNil()) + Expect(pruner).ShouldNot(BeNil()) + + // Register our custom IsPruneableFunc + RegisterIsPrunableFunc(jobGVK, myIsPruneable) + + prunedObjects, err := pruner.Prune(context.Background()) + + Expect(err).Should(BeNil()) + Expect(len(prunedObjects)).Should(Equal(2)) + + // Get a list of the jobs to make sure we have pruned the ones we expected + err = fakeClient.List(context.Background(), jobs) + + Expect(err).Should(BeNil()) + Expect(len(jobs.Items)).Should(Equal(1)) + }) + + It("Should Return An Error If Strategy Function Returns An Error", func() { + // Create the test resources - in this case Jobs + err := createTestJobs(fakeClient) + + Expect(err).Should(BeNil()) + + // Make sure the job resources are properly created + jobs := &unstructured.UnstructuredList{} + jobs.SetGroupVersionKind(jobGVK) + err = fakeClient.List(context.Background(), jobs) + + Expect(err).Should(BeNil()) + Expect(len(jobs.Items)).Should(Equal(3)) + + pruner, err := NewPruner(&rest.Config{}, prunerConfig) + + Expect(err).Should(BeNil()) + Expect(pruner).ShouldNot(BeNil()) + + // Register our custom IsPruneableFunc + RegisterIsPrunableFunc(jobGVK, myIsPruneable) + + // Override pruner strategy with one that will return an error + pruner.Strategy = func(ctx context.Context, objs []client.Object) ([]client.Object, error) { + return nil, fmt.Errorf("TESTERROR") + } + + prunedObjects, err := pruner.Prune(context.Background()) + + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("failed when running Strategy -- ERROR -- TESTERROR")) + Expect(prunedObjects).Should(BeNil()) + + // Get a list of the jobs to make sure we have pruned the ones we expected + err = fakeClient.List(context.Background(), jobs) + + Expect(err).Should(BeNil()) + Expect(len(jobs.Items)).Should(Equal(3)) + }) + }) + + Context("Registry", func() { + It("Should Return a New Registry Object", func() { + registry := NewRegistry() + + Expect(registry).ShouldNot(BeNil()) + }) + + It("Should Add an Entry to Registry Pruneables Map", func() { + registry := NewRegistry() + + Expect(registry).ShouldNot(BeNil()) + + registry.RegisterIsPrunableFunc(podGVK, myIsPruneable) + + Expect(len(registry.Pruneables)).Should(Equal(1)) + Expect(registry.Pruneables).Should(HaveKey(podGVK)) + }) + }) + + Context("DefaultPodIsPruneable", func() { + It("Should Return 'nil' When Criteria Is Met", func() { + // Create a Pod Object + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "churro", + Namespace: "default", + Labels: map[string]string{"app": "churro"}, + }, + Status: v1.PodStatus{ + Phase: v1.PodSucceeded, + }, + } + pod.SetGroupVersionKind(podGVK) + + // Run it through DefaultPodIsPruneable + err := DefaultPodIsPruneable(pod) + + // Check that return is 'nil' + Expect(err).Should(BeNil()) + }) + + It("Should Return An Error When Kind Is Not 'Pod'", func() { + // Create an Unstrutcured with GVK where Kind is not 'Pod' + notPod := &unstructured.Unstructured{} + notPod.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "none", + Version: "v1", + Kind: "NotPod", + }) + + // Run it through DefaultPodIsPruneable + err := DefaultPodIsPruneable(notPod) + + // Check that return is an error + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("can not prune object as it is not a Pod")) + }) + + It("Should Return An Error When Kind Is 'Pod' But Can Not Be Converted To 'Pod'", func() { + // Create a Pod with GVK where Kind is 'Pod' but a value is not able to be converted + pod := &unstructured.Unstructured{} + pod.SetUnstructuredContent(map[string]interface{}{ + "apiVersion": "core/v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "churro", + "namespace": "default", + "labels": map[string]interface{}{ + "app": "churro", + }, + }, + "status": map[string]interface{}{ + // This not being a direct string type should cause a failure in the conversion + "phase": 2000, + }, + }) + pod.SetGroupVersionKind(podGVK) + // Run it through DefaultPodIsPruneable + err := DefaultPodIsPruneable(pod) + // Check that return is an error + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("failed to convert object to kind -- cannot convert int to v1.PodPhase")) + }) + + It("Should Return An Error When Kind Is 'Pod' But Phase Is Not 'Succeeded'", func() { + // Create a Pod Object + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "churro", + Namespace: "default", + Labels: map[string]string{"app": "churro"}, + }, + Status: v1.PodStatus{ + Phase: v1.PodRunning, + }, + } + pod.SetGroupVersionKind(podGVK) + + // Run it through DefaultPodIsPruneable + err := DefaultPodIsPruneable(pod) + + // Check that return is error + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("can not prune Pod as it has not succeeded")) + }) + }) + + Context("DefaultJobIsPruneable", func() { + It("Should Return 'nil' When Criteria Is Met", func() { + // Create a Job Object + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "churro", + Namespace: "default", + Labels: map[string]string{"app": "churro"}, + }, + Status: batchv1.JobStatus{ + CompletionTime: &metav1.Time{Time: metav1.Now().Time}, + }, + } + job.SetGroupVersionKind(jobGVK) + + // Run it through DefaultJobIsPruneable + err := DefaultJobIsPruneable(job) + + // Check that return is 'nil' + Expect(err).Should(BeNil()) + }) + + It("Should Return An Error When Kind Is Not 'Job'", func() { + // Create an Unstrutcured with GVK where Kind is not 'Job' + notJob := &unstructured.Unstructured{} + notJob.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "none", + Version: "v1", + Kind: "NotJob", + }) + + // Run it through DefaultJobIsPruneable + err := DefaultJobIsPruneable(notJob) + + // Check that return is an error + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("can not prune object as it is not a Job")) + }) + + It("Should Return An Error When Kind Is 'Job' But Can Not Be Converted To 'Job'", func() { + // Create a job with GVK where Kind is 'job' but a value is not able to be converted + job := &unstructured.Unstructured{} + job.SetUnstructuredContent(map[string]interface{}{ + "apiVersion": "core/v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "churro", + "namespace": "default", + "labels": map[string]interface{}{ + "app": "churro", + }, + }, + "status": map[string]interface{}{ + // This not being a direct string type should cause a failure in the conversion + "completionTime": 2000, + }, + }) + job.SetGroupVersionKind(jobGVK) + + // Run it through DefaultJobIsPruneable + err := DefaultJobIsPruneable(job) + + // Check that return is an error + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("failed to convert object to kind -- json: cannot unmarshal number into Go value of type string")) + }) + + It("Should Return An Error When Kind Is 'Job' But 'CompletionTime' is 'nil'", func() { + // Create a Job Object + job := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "churro", + Namespace: "default", + Labels: map[string]string{"app": "churro"}, + }, + Status: v1.PodStatus{ + Phase: v1.PodRunning, + }, + } + job.SetGroupVersionKind(jobGVK) + + // Run it through DefaultJobIsPruneable + err := DefaultJobIsPruneable(job) + + // Check that return is error + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("can not prune Job as it has not completed")) + }) + }) + +}) + +// create 3 pods and 3 jobs with different start times (now, 2 days old, 4 days old) +func createTestPods(client client.Client) (err error) { + // some defaults + ns := "default" + appLabel := "churro" + + // Due to some weirdness in the way the fake client is set up we need to create our + // Kubernetes objects via the unstructured.Unstructured method + for i := 0; i < 3; i++ { + pod := &unstructured.Unstructured{} + pod.SetUnstructuredContent(map[string]interface{}{ + "apiVersion": "core/v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": fmt.Sprintf("churro%d", i), + "namespace": ns, + "labels": map[string]interface{}{ + "app": appLabel, + }, + }, + "status": map[string]interface{}{ + "phase": "Succeeded", + }, + }) + pod.SetGroupVersionKind(schema.GroupVersionKind{Group: "core", Version: "v1", Kind: "Pod"}) + err = client.Create(context.Background(), pod) + + if err != nil { + return err + } + } + + return nil +} + +// create 3 pods and 3 jobs with different start times (now, 2 days old, 4 days old) +func createTestJobs(client client.Client) (err error) { + // some defaults + ns := "default" + appLabel := "churro" + + // Due to some weirdness in the way the fake client is set up we need to create our + // Kubernetes objects via the unstructured.Unstructured method + for i := 0; i < 3; i++ { + job := &unstructured.Unstructured{} + job.SetUnstructuredContent(map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]interface{}{ + "name": fmt.Sprintf("churro%d", i), + "namespace": ns, + "labels": map[string]interface{}{ + "app": appLabel, + }, + }, + "status": map[string]interface{}{ + "completionTime": metav1.Now(), + }, + }) + job.SetGroupVersionKind(schema.GroupVersionKind{Group: "batch", Version: "v1", Kind: "Job"}) + err = client.Create(context.Background(), job) + + if err != nil { + return err + } + } + + return nil +} + +// myStrategy shows how you can write your own strategy +// In this example it simply removes a resource if it has +// the name 'churro1' or 'churro2' +func myStrategy(ctx context.Context, objs []client.Object) ([]client.Object, error) { + var objectsToRemove []client.Object + + for _, obj := range objs { + // If the object has name churro1 or churro2 get rid of it + if obj.GetName() == "churro1" || obj.GetName() == "churro2" { + objectsToRemove = append(objectsToRemove, obj) + } + } + + return objectsToRemove, nil +} + +// myIsPruneable shows how you can write your own IsPruneableFunc +// In this example it simply removes all resources +func myIsPruneable(obj client.Object) error { + return nil +} diff --git a/prune/registry.go b/prune/registry.go new file mode 100644 index 0000000..8a20480 --- /dev/null +++ b/prune/registry.go @@ -0,0 +1,46 @@ +// Copyright 2021 The Operator-SDK Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package prune + +import "k8s.io/apimachinery/pkg/runtime/schema" + +type Registry struct { + // Pruneables is a map of GVK to an IsPruneableFunc + Pruneables map[schema.GroupVersionKind]IsPruneableFunc +} + +func NewRegistry() *Registry { + return new(Registry) +} + +var DefaultRegistry = &defaultRegistry + +var defaultRegistry Registry + +// RegisterIsPruneableFunc registers a function to check whether it is safe to prune a resource of a certain type. +func (r *Registry) RegisterIsPrunableFunc(gvk schema.GroupVersionKind, isPruneable IsPruneableFunc) { + if r.Pruneables == nil { + r.Pruneables = make(map[schema.GroupVersionKind]IsPruneableFunc) + } + r.Pruneables[gvk] = isPruneable +} + +// RegisterIsPruneableFunc registers a function to check whether it is safe to prune a resource of a certain type. +func RegisterIsPrunableFunc(gvk schema.GroupVersionKind, isPruneable IsPruneableFunc) { + if DefaultRegistry.Pruneables == nil { + DefaultRegistry.Pruneables = make(map[schema.GroupVersionKind]IsPruneableFunc) + } + DefaultRegistry.Pruneables[gvk] = isPruneable +} diff --git a/prune/remove.go b/prune/remove.go deleted file mode 100644 index c0826b6..0000000 --- a/prune/remove.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2021 The Operator-SDK Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package prune - -import ( - "context" - "fmt" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func (config Config) removeResources(ctx context.Context, resources []ResourceInfo) (err error) { - - if config.DryRun { - return nil - } - - for i := 0; i < len(resources); i++ { - r := resources[i] - - if config.PreDeleteHook != nil { - err = config.PreDeleteHook(ctx, config, r) - if err != nil { - return err - } - } - - switch resources[i].GVK.Kind { - case PodKind: - err := config.Clientset.CoreV1().Pods(r.Namespace).Delete(ctx, r.Name, metav1.DeleteOptions{}) - if err != nil { - return err - } - case JobKind: - err := config.Clientset.BatchV1().Jobs(r.Namespace).Delete(ctx, r.Name, metav1.DeleteOptions{}) - if err != nil { - return err - } - default: - return fmt.Errorf("unsupported resource kind") - } - } - - return nil -} diff --git a/prune/resource_test.go b/prune/resource_test.go deleted file mode 100644 index a5e9f5a..0000000 --- a/prune/resource_test.go +++ /dev/null @@ -1,338 +0,0 @@ -// Copyright 2021 The Operator-SDK Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package prune - -import ( - "context" - "fmt" - "time" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - batchv1 "k8s.io/api/batch/v1" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/kubernetes" - testclient "k8s.io/client-go/kubernetes/fake" - logf "sigs.k8s.io/controller-runtime/pkg/log" -) - -var _ = Describe("Prune", func() { - Describe("test pods", func() { - var ( - client kubernetes.Interface - cfg Config - ctx context.Context - ) - BeforeEach(func() { - client = testclient.NewSimpleClientset() - ctx = context.Background() - cfg = Config{ - Log: logf.Log.WithName("prune"), - DryRun: false, - Clientset: client, - LabelSelector: "app=churro", - Resources: []schema.GroupVersionKind{ - {Group: "", Version: "", Kind: PodKind}, - }, - Namespaces: []string{"default"}, - Strategy: StrategyConfig{ - Mode: MaxCountStrategy, - MaxCountSetting: 1, - }, - PreDeleteHook: myhook, - } - - _ = createTestPods(client) - }) - It("test pod maxCount strategy", func() { - err := cfg.Execute(ctx) - Expect(err).Should(BeNil()) - var pods []ResourceInfo - pods, err = cfg.getSucceededPods(ctx) - Expect(err).Should(BeNil()) - Expect(len(pods)).To(Equal(1)) - Expect(containsName(pods, "churro1")).To(Equal(true)) - }) - It("test pod maxAge strategy", func() { - cfg.Strategy.Mode = MaxAgeStrategy - cfg.Strategy.MaxAgeSetting = "3h" - err := cfg.Execute(ctx) - Expect(err).Should(BeNil()) - var pods []ResourceInfo - pods, err = cfg.getSucceededPods(ctx) - Expect(err).Should(BeNil()) - Expect(containsName(pods, "churro1")).To(Equal(true)) - Expect(containsName(pods, "churro2")).To(Equal(true)) - }) - It("test pod custom strategy", func() { - cfg.Strategy.Mode = CustomStrategy - cfg.Strategy.CustomSettings = make(map[string]interface{}) - cfg.CustomStrategy = myStrategy - err := cfg.Execute(ctx) - Expect(err).Should(BeNil()) - var pods []ResourceInfo - pods, err = cfg.getSucceededPods(ctx) - Expect(err).Should(BeNil()) - Expect(len(pods)).To(Equal(3)) - }) - }) - - Describe("config validation", func() { - var ( - ctx context.Context - cfg Config - ) - BeforeEach(func() { - cfg = Config{} - cfg.Log = logf.Log.WithName("prune") - ctx = context.Background() - }) - It("should return an error when LabelSelector is not set", func() { - err := cfg.Execute(ctx) - Expect(err).ShouldNot(BeNil()) - }) - It("should return an error is Namespaces is empty", func() { - cfg.LabelSelector = "app=churro" - err := cfg.Execute(ctx) - Expect(err).ShouldNot(BeNil()) - }) - It("should return an error when labels dont parse", func() { - cfg.Namespaces = []string{"one"} - cfg.LabelSelector = "-" - err := cfg.Execute(ctx) - Expect(err).ShouldNot(BeNil()) - }) - }) - - Describe("test jobs", func() { - var ( - jobclient kubernetes.Interface - jobcfg Config - ctx context.Context - ) - BeforeEach(func() { - jobclient = testclient.NewSimpleClientset() - - ctx = context.Background() - jobcfg = Config{ - DryRun: false, - Log: logf.Log.WithName("prune"), - Clientset: jobclient, - LabelSelector: "app=churro", - Resources: []schema.GroupVersionKind{ - {Group: "", Version: "", Kind: JobKind}, - }, - Namespaces: []string{"default"}, - Strategy: StrategyConfig{ - Mode: MaxCountStrategy, - MaxCountSetting: 1, - }, - PreDeleteHook: myhook, - } - - _ = createTestJobs(jobclient) - }) - It("test job maxAge strategy", func() { - jobcfg.Strategy.Mode = MaxAgeStrategy - jobcfg.Strategy.MaxAgeSetting = "3h" - err := jobcfg.Execute(ctx) - Expect(err).Should(BeNil()) - var jobs []ResourceInfo - jobs, err = jobcfg.getCompletedJobs(ctx) - Expect(err).Should(BeNil()) - Expect(containsName(jobs, "churro1")).To(Equal(true)) - Expect(containsName(jobs, "churro2")).To(Equal(true)) - }) - It("test job maxCount strategy", func() { - err := jobcfg.Execute(ctx) - Expect(err).Should(BeNil()) - var jobs []ResourceInfo - jobs, err = jobcfg.getCompletedJobs(ctx) - Expect(err).Should(BeNil()) - Expect(len(jobs)).To(Equal(1)) - Expect(containsName(jobs, "churro1")).To(Equal(true)) - }) - It("test job custom strategy", func() { - jobcfg.Strategy.Mode = CustomStrategy - jobcfg.Strategy.CustomSettings = make(map[string]interface{}) - jobcfg.CustomStrategy = myStrategy - err := jobcfg.Execute(ctx) - Expect(err).Should(BeNil()) - var jobs []ResourceInfo - jobs, err = jobcfg.getCompletedJobs(ctx) - Expect(err).Should(BeNil()) - Expect(len(jobs)).To(Equal(3)) - }) - }) -}) - -// create 3 jobs with different start times (now, 2 days old, 4 days old) -func createTestJobs(client kubernetes.Interface) (err error) { - // some defaults - ns := "default" - labels := make(map[string]string) - labels["app"] = "churro" - - // delete any existing jobs - _ = client.BatchV1().Jobs(ns).Delete(context.TODO(), "churro1", metav1.DeleteOptions{}) - _ = client.BatchV1().Jobs(ns).Delete(context.TODO(), "churro2", metav1.DeleteOptions{}) - _ = client.BatchV1().Jobs(ns).Delete(context.TODO(), "churro3", metav1.DeleteOptions{}) - - // create 3 jobs with different CompletionTime - now := time.Now() //initial start time - startTime := metav1.NewTime(now) - j1 := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "churro1", - Namespace: ns, - Labels: labels, - }, - Status: batchv1.JobStatus{ - CompletionTime: &startTime, - }, - } - _, err = client.BatchV1().Jobs(ns).Create(context.TODO(), j1, metav1.CreateOptions{}) - if err != nil { - return err - } - - twoHoursPriorToNow := now.Add(time.Hour * time.Duration(-2)) - // create start time 2 hours before now - startTime = metav1.NewTime(twoHoursPriorToNow) - j2 := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "churro2", - Namespace: ns, - Labels: labels, - }, - Status: batchv1.JobStatus{ - CompletionTime: &startTime, - }, - } - _, err = client.BatchV1().Jobs(ns).Create(context.TODO(), j2, metav1.CreateOptions{}) - if err != nil { - return err - } - // create start time 4 hours before now - fourHoursPriorToNow := now.Add(time.Hour * time.Duration(-4)) - startTime = metav1.NewTime(fourHoursPriorToNow) - j3 := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "churro3", - Namespace: ns, - Labels: labels, - }, - Status: batchv1.JobStatus{ - CompletionTime: &startTime, - }, - } - _, err = client.BatchV1().Jobs(ns).Create(context.TODO(), j3, metav1.CreateOptions{}) - if err != nil { - return err - } - return nil -} - -// create 3 pods and 3 jobs with different start times (now, 2 days old, 4 days old) -func createTestPods(client kubernetes.Interface) (err error) { - // some defaults - ns := "default" - labels := make(map[string]string) - labels["app"] = "churro" - - // delete any existing pods - _ = client.CoreV1().Pods(ns).Delete(context.TODO(), "churro1", metav1.DeleteOptions{}) - _ = client.CoreV1().Pods(ns).Delete(context.TODO(), "churro2", metav1.DeleteOptions{}) - _ = client.CoreV1().Pods(ns).Delete(context.TODO(), "churro3", metav1.DeleteOptions{}) - - // create 3 pods with different StartTimes - now := time.Now() //initial start time - startTime := metav1.NewTime(now) - p1 := &v1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "churro1", - Namespace: ns, - Labels: labels, - }, - Status: v1.PodStatus{ - Phase: v1.PodSucceeded, - StartTime: &startTime, - }, - } - _, err = client.CoreV1().Pods(ns).Create(context.TODO(), p1, metav1.CreateOptions{}) - if err != nil { - return err - } - - twoHoursPriorToNow := now.Add(time.Hour * time.Duration(-2)) - // create start time 2 hours before now - startTime = metav1.NewTime(twoHoursPriorToNow) - p2 := &v1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "churro2", - Namespace: ns, - Labels: labels, - }, - Status: v1.PodStatus{ - Phase: v1.PodSucceeded, - StartTime: &startTime, - }, - } - _, err = client.CoreV1().Pods(ns).Create(context.TODO(), p2, metav1.CreateOptions{}) - if err != nil { - return err - } - // create start time 4 hours before now - fourHoursPriorToNow := now.Add(time.Hour * time.Duration(-4)) - startTime = metav1.NewTime(fourHoursPriorToNow) - p3 := &v1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "churro3", - Namespace: ns, - Labels: labels, - }, - Status: v1.PodStatus{ - Phase: v1.PodSucceeded, - StartTime: &startTime, - }, - } - _, err = client.CoreV1().Pods(ns).Create(context.TODO(), p3, metav1.CreateOptions{}) - if err != nil { - return err - } - - return nil -} - -func myhook(ctx context.Context, cfg Config, x ResourceInfo) error { - log := Logger(ctx, cfg) - log.V(1).Info("myhook is called") - return nil -} - -// myStrategy shows how you can write your own strategy, in this -// example, the strategy doesn't really do another other than count -// the number of resources, returning a list of resources to delete in -// this case zero. -func myStrategy(ctx context.Context, cfg Config, resources []ResourceInfo) (resourcesToRemove []ResourceInfo, err error) { - log := Logger(ctx, cfg) - log.V(1).Info("myStrategy is called", "resources", resources, "config", cfg) - if len(resources) != 3 { - return resourcesToRemove, fmt.Errorf("count of resources did not equal our expectation") - } - return resourcesToRemove, nil -} diff --git a/prune/resources.go b/prune/resources.go deleted file mode 100644 index f3e7cbf..0000000 --- a/prune/resources.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright 2021 The Operator-SDK Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package prune - -import ( - "context" - "sort" - "time" - - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" -) - -// ResourceInfo describes the Kube resources that we are about to consider -// when pruning resources -type ResourceInfo struct { - Name string - GVK schema.GroupVersionKind - Namespace string - StartTime time.Time -} - -func (config Config) getSucceededPods(ctx context.Context) (resources []ResourceInfo, err error) { - - listOptions := metav1.ListOptions{LabelSelector: config.LabelSelector} - for n := 0; n < len(config.Namespaces); n++ { - pods, err := config.Clientset.CoreV1().Pods(config.Namespaces[n]).List(ctx, listOptions) - if err != nil { - return resources, err - } - - for i := 0; i < len(pods.Items); i++ { - p := pods.Items[i] - switch p.Status.Phase { - case v1.PodRunning: - case v1.PodPending: - case v1.PodFailed: - case v1.PodUnknown: - case v1.PodSucceeded: - // currently we only care to prune succeeded pods - resources = append(resources, ResourceInfo{ - Name: p.Name, - GVK: schema.GroupVersionKind{ - Kind: PodKind, - }, - Namespace: config.Namespaces[n], - StartTime: p.Status.StartTime.Time, - }) - default: - } - } - } - - // sort by StartTime, earliest first order - sort.Slice(resources, func(i, j int) bool { - return resources[i].StartTime.After(resources[j].StartTime) - }) - - return resources, nil -} - -func (config Config) getCompletedJobs(ctx context.Context) (resources []ResourceInfo, err error) { - - listOptions := metav1.ListOptions{LabelSelector: config.LabelSelector} - - for n := 0; n < len(config.Namespaces); n++ { - jobs, err := config.Clientset.BatchV1().Jobs(config.Namespaces[n]).List(ctx, listOptions) - if err != nil { - return resources, err - } - for i := 0; i < len(jobs.Items); i++ { - j := jobs.Items[i] - if j.Status.CompletionTime != nil { - // currently we only care to prune succeeded pods - resources = append(resources, ResourceInfo{ - Name: j.Name, - GVK: schema.GroupVersionKind{ - Kind: JobKind, - }, - Namespace: config.Namespaces[n], - StartTime: j.Status.CompletionTime.Time, - }) - } - } - } - - // sort by StartTime, earliest first order - sort.Slice(resources, func(i, j int) bool { - return resources[i].StartTime.After(resources[j].StartTime) - }) - - return resources, nil -}