From 3bba9d7870da70af6259733fa7b4167906887b58 Mon Sep 17 00:00:00 2001 From: Bryce Palmer Date: Thu, 14 Apr 2022 16:05:27 -0400 Subject: [PATCH] update prune package improve the existing prune package fixes #101 Signed-off-by: Bryce Palmer --- .github/workflows/ci.yml | 4 +- go.mod | 6 +- go.sum | 9 +- prune/maxage.go | 46 --- prune/maxcount.go | 48 --- prune/prunables.go | 55 +++ prune/prune.go | 322 +++++++++++------- prune/prune_test.go | 709 +++++++++++++++++++++++++++++++++++++++ prune/registry.go | 67 ++++ prune/remove.go | 57 ---- prune/resource_test.go | 338 ------------------- prune/resources.go | 106 ------ 12 files changed, 1038 insertions(+), 729 deletions(-) delete mode 100644 prune/maxage.go delete mode 100644 prune/maxcount.go create mode 100644 prune/prunables.go create mode 100644 prune/prune_test.go create mode 100644 prune/registry.go delete mode 100644 prune/remove.go delete mode 100644 prune/resource_test.go delete mode 100644 prune/resources.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acb5ad0..799f1bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go 1.x uses: actions/setup-go@v2 with: - go-version: ^1.17 + go-version: 1.17.x id: go - name: Check out code into the Go module directory @@ -50,7 +50,7 @@ jobs: uses: actions/checkout@v2 - name: Run golangci-lint - uses: golangci/golangci-lint-action@v2 + uses: golangci/golangci-lint-action@v3 with: version: v1.29 args: --timeout 5m diff --git a/go.mod b/go.mod index 837eb82..eb1e9fc 100644 --- a/go.mod +++ b/go.mod @@ -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..6cc0bae 100644 --- a/go.sum +++ b/go.sum @@ -665,8 +665,9 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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= @@ -764,13 +765,15 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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..1fb4ce3 --- /dev/null +++ b/prune/prunables.go @@ -0,0 +1,55 @@ +// 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 ( + "sigs.k8s.io/controller-runtime/pkg/client" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" +) + +// Some default pruneable functions + +// DefaultPodIsPrunable is a default IsPrunableFunc to be used specifically with Pod resources. +// This can be overridden by registering your own IsPrunableFunc via the RegisterIsPrunableFunc method +func DefaultPodIsPrunable(obj client.Object) error { + pod := obj.(*corev1.Pod) + if pod.Status.Phase != corev1.PodSucceeded { + return &Unprunable{ + Obj: &obj, + Reason: "Pod has not succeeded", + } + } + + return nil +} + +// DefaultJobIsPrunable is a default IsPrunableFunc to be used specifically with Job resources. +// This can be overridden by registering your own IsPrunableFunc via the RegisterIsPrunableFunc method +func DefaultJobIsPrunable(obj client.Object) error { + + job := obj.(*batchv1.Job) + + // If the job has completed we can remove it + if job.Status.CompletionTime == nil { + return &Unprunable{ + Obj: &obj, + Reason: "Job has not completed", + } + } + + return nil +} diff --git a/prune/prune.go b/prune/prune.go index 62133cd..48ec4ae 100644 --- a/prune/prune.go +++ b/prune/prune.go @@ -16,184 +16,254 @@ package prune import ( "context" + "errors" "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/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 + + // 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 + + // GVK is the type of objects to prune. + // It defaults to Pod + GVK schema.GroupVersionKind + + // Strategy is the function used to determine a list of resources that are pruneable + Strategy StrategyFunc + + // Labels is a map of the labels to use for label matching when looking for resources + Labels map[string]string -// StrategyConfig holds settings unique to each pruning mode -type StrategyConfig struct { - Mode Strategy - MaxAgeSetting string - MaxCountSetting int - CustomSettings map[string]interface{} + // 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 +// Unprunable indicates that it is not allowed to prune a specific object. +type Unprunable 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") +// Error returns a string representation of an `Unprunable` error. +func (e *Unprunable) Error() string { + return fmt.Sprintf("unable to prune %s: %s", client.ObjectKeyFromObject(*e.Obj), e.Reason) +} - err := config.validate() - if err != nil { - return err - } - - for i := 0; i < len(config.Resources); i++ { - var resourceList []ResourceInfo - var err error - - 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)) - } +// StrategyFunc takes a list of resources and returns the subset to prune. +type StrategyFunc func(ctx context.Context, objs []client.Object) ([]client.ObjectKey, error) - 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 - } +// IsPrunableFunc is a function that checks 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, `Unprunable` if it is unsafe, or another error. +// It should safely assert the object is the expected type, otherwise it might panic. +type IsPrunableFunc func(obj client.Object) error - err = config.removeResources(ctx, resourcesToRemove) - if err != nil { - return err - } +// PrunerOption configures the pruner. +type PrunerOption func(p *Pruner) + +// SetRegistry can be used to set the Registry field when configuring a Pruner +func SetRegistry(registry Registry) PrunerOption { + return func(p *Pruner) { + p.Registry = registry } +} - log.V(1).Info("Prune completed") +// DryRun can be used to set the DryRun field to true when configuring a Pruner +func DryRun() PrunerOption { + return func(p *Pruner) { + p.DryRun = true + } +} - return nil +// SetStrategy can be used to set the Strategy field when configuring a Pruner +func SetStrategy(strategy StrategyFunc) PrunerOption { + return func(p *Pruner) { + p.Strategy = strategy + } } -// 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 - } +// Namespace can be used to set the Namespace field when configuring a Pruner +func Namespace(namespace string) PrunerOption { + return func(p *Pruner) { + p.Namespace = namespace } +} - return false +// SetLogger can be used to set the Logger field when configuring a Pruner +func SetLogger(logger logr.Logger) PrunerOption { + return func(p *Pruner) { + p.Logger = logger + } } -// 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 - } +// Labels can be used to set the Labels field when configuring a Pruner +func Labels(labels map[string]string) PrunerOption { + return func(p *Pruner) { + p.Labels = labels } +} - return false +// GVK can be used to set the GVK field when configuring a Pruner +func GVK(gvk schema.GroupVersionKind) PrunerOption { + return func(p *Pruner) { + p.GVK = gvk + } } -func (config Config) validate() (err error) { - if config.CustomStrategy == nil && config.Strategy.Mode == CustomStrategy { - return fmt.Errorf("custom strategies require a strategy function to be specified") +// NewPruner returns a pruner that uses the given strategy to prune objects. +func NewPruner(prunerClient client.Client, opts ...PrunerOption) Pruner { + podGVK := schema.GroupVersionKind{ + Group: "core", + Version: "v1", + Kind: "Pod", } - if len(config.Namespaces) == 0 { - return fmt.Errorf("namespaces are required") + jobGVK := schema.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", } - if containsString(config.Namespaces, "") { - return fmt.Errorf("empty namespace value not supported") + pruner := Pruner{ + Registry: defaultRegistry, + Client: prunerClient, + DryRun: false, + Logger: Logger(context.Background(), Pruner{}), + GVK: podGVK, } - _, err = labels.Parse(config.LabelSelector) - if err != nil { - return err + // Populate the default IsPrunableFunc(s) + RegisterIsPrunableFunc(podGVK, DefaultPodIsPrunable) + + RegisterIsPrunableFunc(jobGVK, DefaultJobIsPrunable) + + for _, opt := range opts { + opt(&pruner) + } + + return pruner +} + +// Prune runs the pruner. +func (p Pruner) Prune(ctx context.Context) ([]client.ObjectKey, error) { + var objs []client.Object + p.Logger.Info("Starting the pruning process...") + listOpts := client.ListOptions{ + LabelSelector: labels.Set(p.Labels).AsSelector(), + Namespace: p.Namespace, + } + + var unstructuredObjs unstructured.UnstructuredList + unstructuredObjs.SetGroupVersionKind(p.GVK) + if err := p.Client.List(ctx, &unstructuredObjs, &listOpts); err != nil { + p.Logger.Error(err, "failed to get a list of resources for pruning", "Labels", p.Labels, "Namespace", p.Namespace) + return nil, fmt.Errorf("failed to get list of objects -- ERROR -- %s", err) } - if config.Strategy.Mode == MaxAgeStrategy { - _, err = time.ParseDuration(config.Strategy.MaxAgeSetting) + for _, unsObj := range unstructuredObjs.Items { + obj, err := convert(p.Client, p.GVK, &unsObj) if err != nil { - return err + return nil, err } + + if err := p.IsPrunable(obj); isUnprunable(err) { + continue + } else if err != nil { + return nil, err + } + + objs = append(objs, obj) } - 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") + + objsToPrune, err := p.Strategy(ctx, objs) + if err != nil { + p.Logger.Error(err, "failed to get a list of resources to prune from Strategy") + return nil, fmt.Errorf("failed when running Strategy -- ERROR -- %s", err) + } + + if p.DryRun { + // print out objects + return objsToPrune, nil + } + + // Prune the resources + for _, obj := range objsToPrune { + // Prune + prunableObj := &unstructured.Unstructured{} + prunableObj.SetName(obj.Name) + prunableObj.SetNamespace(obj.Namespace) + prunableObj.SetGroupVersionKind(p.GVK) + + if err = p.Client.Delete(ctx, prunableObj); err != nil { + p.Logger.Error(err, "failed to prune resource", "Resource", obj) + return nil, fmt.Errorf("failed to prune object -- ERROR -- %s", err) } } - return nil + + return objsToPrune, 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) } return log.WithValues(keysAndValues...) } + +func isUnprunable(target error) bool { + var unprunable *Unprunable + return errors.As(target, &unprunable) +} + +func convert(c client.Client, gvk schema.GroupVersionKind, obj client.Object) (client.Object, error) { + obj2, err := c.Scheme().New(gvk) + if err != nil { + return nil, err + } + objConverted := obj2.(client.Object) + if err := c.Scheme().Convert(obj, objConverted, nil); err != nil { + return nil, err + } + + objConverted.GetObjectKind().SetGroupVersionKind(gvk) + + return objConverted, nil +} diff --git a/prune/prune_test.go b/prune/prune_test.go new file mode 100644 index 0000000..505c53c --- /dev/null +++ b/prune/prune_test.go @@ -0,0 +1,709 @@ +// 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" + "errors" + "fmt" + + "github.com/go-logr/logr" + . "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" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/scheme" + + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + crFake "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const namespace = "default" +const app = "churro" + +var _ = Describe("Prune", func() { + var ( + fakeClient client.Client + fakeObj client.Object + prunerConfig PrunerOption + podGVK schema.GroupVersionKind + jobGVK schema.GroupVersionKind + ) + BeforeEach(func() { + testScheme, err := createSchemes() + + Expect(err).Should(BeNil()) + + fakeClient = crFake.NewClientBuilder().WithScheme(testScheme).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"] = app + + // Set our strategy + p.Strategy = myStrategy + + p.Labels = labels + + p.Namespace = namespace + p.Client = fakeClient + } + + podGVK = schema.GroupVersionKind{ + Group: "core", + Version: "v1", + Kind: "Pod", + } + + jobGVK = schema.GroupVersionKind{ + Group: "batch", + Version: "v1", + Kind: "Job", + } + + }) + + Describe("Unprunable", func() { + Describe("Error()", func() { + It("Should Return a String Representation of Unprunable", func() { + unpruneable := Unprunable{ + Obj: &fakeObj, + Reason: "TestReason", + } + + Expect(unpruneable.Error()).To(Equal(fmt.Sprintf("unable to prune %s: %s", client.ObjectKeyFromObject(fakeObj), unpruneable.Reason))) + }) + }) + }) + + Describe("Registry", func() { + Describe("NewRegistry()", func() { + It("Should Return a New Registry Object", func() { + registry := NewRegistry() + + Expect(registry).ShouldNot(BeNil()) + }) + }) + + Describe("RegisterIsPrunableFunc()", func() { + It("Should Add an Entry to Registry Prunables Map", func() { + registry := NewRegistry() + + Expect(registry).ShouldNot(BeNil()) + + registry.RegisterIsPrunableFunc(podGVK, myIsPrunable) + }) + }) + + Describe("IsPrunable()", func() { + It("Should Return 'nil' if object GVK is not found in Prunables Map", func() { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "group", + Version: "v1", + Kind: "NotReal", + }) + + Expect(IsPrunable(obj)).Should(BeNil()) + }) + }) + + }) + Describe("Pruner", func() { + Describe("NewPruner()", func() { + It("Should Return a New Pruner Object", func() { + pruner := NewPruner(fakeClient) + + Expect(pruner).ShouldNot(BeNil()) + }) + + It("Should Return a New Pruner Object with Custom Configuration", func() { + registry := NewRegistry() + namespace := "namespace" + labels := map[string]string{"app": "churro"} + logger := &logr.Logger{} + pruner := NewPruner(fakeClient, + SetRegistry(*registry), + Namespace(namespace), + Labels(labels), + DryRun(), + SetStrategy(nil), + SetLogger(*logger), + GVK(jobGVK)) + + Expect(pruner).ShouldNot(BeNil()) + Expect(&pruner.Registry).Should(Equal(registry)) + Expect(pruner.Namespace).Should(Equal(namespace)) + Expect(pruner.Labels).Should(Equal(labels)) + Expect(&pruner.Logger).Should(Equal(logger)) + Expect(pruner.Strategy).Should(BeNil()) + Expect(pruner.DryRun).Should(BeTrue()) + Expect(pruner.GVK).Should(Equal(jobGVK)) + Expect(pruner.Client).Should(Equal(fakeClient)) + }) + + }) + + Describe("Prune()", func() { + Context("Does not return an Error", func() { + It("Should Prune Pods with Default IsPrunableFunc", 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 := NewPruner(fakeClient, prunerConfig) + + 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 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 := NewPruner(fakeClient, prunerConfig, GVK(jobGVK)) + + 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 := NewPruner(fakeClient, prunerConfig, GVK(jobGVK)) + + Expect(pruner).ShouldNot(BeNil()) + + // Register our custom IsPrunableFunc + RegisterIsPrunableFunc(jobGVK, myIsPrunable) + + 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 Not Prune Resources when DryRun is set to true", 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 := NewPruner(fakeClient, prunerConfig) + + Expect(pruner).ShouldNot(BeNil()) + + pruner.DryRun = true + + 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 haven't pruned any + err = fakeClient.List(context.Background(), pods) + + Expect(err).Should(BeNil()) + Expect(len(pods.Items)).Should(Equal(3)) + }) + + It("Should Skip Pruning a Resource If IsPrunable Returns an Error of Type Unprunable", 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 := NewPruner(fakeClient, prunerConfig, GVK(jobGVK)) + + Expect(pruner).ShouldNot(BeNil()) + + // IsPrunableFunc that throws Unprunable error + errorPrunableFunc := func(obj client.Object) error { + return &Unprunable{ + Obj: &obj, + Reason: "TEST", + } + } + + // Register our custom IsPrunableFunc + RegisterIsPrunableFunc(jobGVK, errorPrunableFunc) + + prunedObjects, err := pruner.Prune(context.Background()) + + Expect(err).Should(BeNil()) + Expect(len(prunedObjects)).Should(Equal(0)) + + // 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("Returns an Error", func() { + It("Should Return an Error if IsPrunableFunc Returns an Error That is not of Type Unprunable", 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 := NewPruner(fakeClient, prunerConfig, GVK(jobGVK)) + + Expect(pruner).ShouldNot(BeNil()) + + // IsPrunableFunc that throws non Unprunable error + errorPrunableFunc := func(obj client.Object) error { + return fmt.Errorf("TEST") + } + + // Register our custom IsPrunableFunc + RegisterIsPrunableFunc(jobGVK, errorPrunableFunc) + + prunedObjects, err := pruner.Prune(context.Background()) + + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal("TEST")) + Expect(len(prunedObjects)).Should(Equal(0)) + + // 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)) + }) + + 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 := NewPruner(fakeClient, prunerConfig) + + Expect(err).Should(BeNil()) + Expect(pruner).ShouldNot(BeNil()) + + // Register our custom IsPrunableFunc + RegisterIsPrunableFunc(jobGVK, myIsPrunable) + + // Override pruner strategy with one that will return an error + pruner.Strategy = func(ctx context.Context, objs []client.Object) ([]client.ObjectKey, 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)) + }) + + It("Should Return an Error if it can not Prune a Resource", 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 := NewPruner(fakeClient, prunerConfig, GVK(jobGVK)) + + Expect(err).Should(BeNil()) + Expect(pruner).ShouldNot(BeNil()) + + // IsPrunableFunc that returns nil but also deletes the object + // so that it will throw an error when attempting to remove the object + prunableFunc := func(obj client.Object) error { + _ = fakeClient.Delete(context.TODO(), obj, &client.DeleteOptions{}) + return nil + } + + // Register our custom IsPrunableFunc + RegisterIsPrunableFunc(jobGVK, prunableFunc) + + prunedObjects, err := pruner.Prune(context.Background()) + + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).Should(ContainSubstring("failed to prune object -- ERROR -- jobs.batch \"churro1\" not found")) + Expect(len(prunedObjects)).Should(Equal(0)) + + // 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(0)) + }) + + }) + }) + }) + + Context("DefaultPodIsPrunable", func() { + It("Should Return 'nil' When Criteria Is Met", func() { + // Create a Pod Object + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: app, + Namespace: namespace, + Labels: map[string]string{"app": app}, + }, + Status: v1.PodStatus{ + Phase: v1.PodSucceeded, + }, + } + pod.SetGroupVersionKind(podGVK) + + // Run it through DefaultPodIsPrunable + err := DefaultPodIsPrunable(pod) + + // Check that return is 'nil' + Expect(err).Should(BeNil()) + }) + + It("Should Panic When client.Object is not of type 'Pod'", func() { + // Create an Unstrutcured with GVK where Kind is not 'Pod' + notPod := &unstructured.Unstructured{} + + defer expectPanic() + + // Run it through DefaultPodIsPrunable + _ = DefaultPodIsPrunable(notPod) + }) + + 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: app, + Namespace: namespace, + Labels: map[string]string{"app": app}, + }, + Status: v1.PodStatus{ + Phase: v1.PodRunning, + }, + } + pod.SetGroupVersionKind(podGVK) + + // Run it through DefaultPodIsPrunable + err := DefaultPodIsPrunable(pod) + + // Check that return is error + Expect(err).ShouldNot(BeNil()) + var expectErr *Unprunable + Expect(errors.As(err, &expectErr)).Should(BeTrue()) + Expect(expectErr.Reason).Should(Equal("Pod has not succeeded")) + Expect(expectErr.Obj).ShouldNot(BeNil()) + Expect(err.Error()).Should(Equal(fmt.Sprintf("unable to prune %s: Pod has not succeeded", client.ObjectKeyFromObject(pod)))) + }) + }) + + Context("DefaultJobIsPrunable", func() { + It("Should Return 'nil' When Criteria Is Met", func() { + // Create a Job Object + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: app, + Namespace: namespace, + Labels: map[string]string{"app": app}, + }, + Status: batchv1.JobStatus{ + CompletionTime: &metav1.Time{Time: metav1.Now().Time}, + }, + } + job.SetGroupVersionKind(jobGVK) + + // Run it through DefaultJobIsPrunable + err := DefaultJobIsPrunable(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{} + + defer expectPanic() + + // Run it through DefaultJobIsPrunable + _ = DefaultJobIsPrunable(notJob) + }) + + It("Should Return An Error When Kind Is 'Job' But 'CompletionTime' is 'nil'", func() { + // Create a Job Object + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: app, + Namespace: namespace, + Labels: map[string]string{"app": app}, + }, + Status: batchv1.JobStatus{ + CompletionTime: nil, + }, + } + job.SetGroupVersionKind(jobGVK) + + // Run it through DefaultJobIsPrunable + err := DefaultJobIsPrunable(job) + + // Check that return is error + Expect(err).ShouldNot(BeNil()) + var expectErr *Unprunable + Expect(errors.As(err, &expectErr)).Should(BeTrue()) + Expect(expectErr.Reason).Should(Equal("Job has not completed")) + Expect(expectErr.Obj).ShouldNot(BeNil()) + Expect(err.Error()).Should(ContainSubstring(fmt.Sprintf("unable to prune %s: Job has not completed", client.ObjectKeyFromObject(job)))) + }) + }) + +}) + +// 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 := namespace + appLabel := app + + // 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 := namespace + appLabel := app + + // 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 +} + +// createSchemes is a helper function to set up the schemes needed to run +// our tests utilizing controller-runtime's fake client +func createSchemes() (*runtime.Scheme, error) { + + corev1GV := schema.GroupVersion{Group: "core", Version: "v1"} + corev1SchemeBuilder := &scheme.Builder{GroupVersion: corev1GV} + corev1SchemeBuilder.Register(&v1.Pod{}, &v1.PodList{}) + + batchv1GV := schema.GroupVersion{Group: "batch", Version: "v1"} + batchv1SchemeBuilder := &scheme.Builder{GroupVersion: batchv1GV} + batchv1SchemeBuilder.Register(&batchv1.Job{}, &batchv1.JobList{}) + + outScheme := runtime.NewScheme() + + err := corev1SchemeBuilder.AddToScheme(outScheme) + if err != nil { + return nil, err + } + + err = batchv1SchemeBuilder.AddToScheme(outScheme) + if err != nil { + return nil, err + } + + return outScheme, 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.ObjectKey, error) { + var objsToRemove []client.ObjectKey + + for _, obj := range objs { + // If the object has name churro1 or churro2 get rid of it + if obj.GetName() == "churro1" || obj.GetName() == "churro2" { + objsToRemove = append(objsToRemove, client.ObjectKeyFromObject(obj)) + } + } + + return objsToRemove, nil +} + +// expectPanic is a helper function for testing functions that are expected to panic +// when used it should be used with a defer statement before the function +// that is expected to panic is called +func expectPanic() { + r := recover() + + Expect(r).ShouldNot(BeNil()) +} + +// myIsPrunable shows how you can write your own IsPrunableFunc +// In this example it simply removes all resources +func myIsPrunable(obj client.Object) error { + return nil +} diff --git a/prune/registry.go b/prune/registry.go new file mode 100644 index 0000000..18c9a88 --- /dev/null +++ b/prune/registry.go @@ -0,0 +1,67 @@ +// 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" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Registry is used to register a mapping of GroupVersionKind to an IsPrunableFunc +type Registry struct { + // prunables is a map of GVK to an IsPrunableFunc + prunables map[schema.GroupVersionKind]IsPrunableFunc +} + +// NewRegistry creates a new Registry +func NewRegistry() *Registry { + return new(Registry) +} + +// DefaultRegistry is a default Registry configuration +func DefaultRegistry() *Registry { + return &defaultRegistry +} + +var defaultRegistry Registry + +// RegisterIsPrunableFunc registers a function to check whether it is safe to prune a resource of a certain type. +func (r *Registry) RegisterIsPrunableFunc(gvk schema.GroupVersionKind, isPrunable IsPrunableFunc) { + if r.prunables == nil { + r.prunables = make(map[schema.GroupVersionKind]IsPrunableFunc) + } + + r.prunables[gvk] = isPrunable +} + +// IsPrunable checks if an object is prunable +func (r *Registry) IsPrunable(obj client.Object) error { + isPrunable, ok := r.prunables[obj.GetObjectKind().GroupVersionKind()] + if !ok { + return nil + } + + return isPrunable(obj) +} + +// RegisterIsPrunableFunc registers a function to check whether it is safe to prune a resource of a certain type. +func RegisterIsPrunableFunc(gvk schema.GroupVersionKind, isPrunable IsPrunableFunc) { + DefaultRegistry().RegisterIsPrunableFunc(gvk, isPrunable) +} + +// IsPrunable checks if an object is prunable +func IsPrunable(obj client.Object) error { + return DefaultRegistry().IsPrunable(obj) +} 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 -}