From f2955fedd04e50124316f660e9bf17f3460c4fb8 Mon Sep 17 00:00:00 2001
From: Angus Lees <gus@inodes.org>
Date: Fri, 14 Jul 2017 17:04:09 +1000
Subject: [PATCH] Implement garbage collection

Fixes #15
---
 cmd/root.go        |  12 ++-
 cmd/update.go      | 225 +++++++++++++++++++++++++++++++++++++++++++--
 cmd/update_test.go |  81 ++++++++++++++++
 utils/meta.go      |  11 +++
 4 files changed, 320 insertions(+), 9 deletions(-)
 create mode 100644 cmd/update_test.go

diff --git a/cmd/root.go b/cmd/root.go
index 152847ce..0f7e9842 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -31,8 +31,10 @@ import (
 	"github.com/spf13/cobra"
 	jsonnet "github.com/strickyak/jsonnet_cgo"
 	"golang.org/x/crypto/ssh/terminal"
+	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/client-go/discovery"
 	"k8s.io/client-go/dynamic"
@@ -375,8 +377,8 @@ func serverResourceForGroupVersionKind(disco discovery.DiscoveryInterface, gvk s
 	return nil, fmt.Errorf("Server is unable to handle %s", gvk)
 }
 
-func clientForResource(pool dynamic.ClientPool, disco discovery.DiscoveryInterface, obj *unstructured.Unstructured, defNs string) (*dynamic.ResourceClient, error) {
-	gvk := obj.GroupVersionKind()
+func clientForResource(pool dynamic.ClientPool, disco discovery.DiscoveryInterface, obj runtime.Object, defNs string) (*dynamic.ResourceClient, error) {
+	gvk := obj.GetObjectKind().GroupVersionKind()
 
 	client, err := pool.ClientForGroupVersionKind(gvk)
 	if err != nil {
@@ -388,7 +390,11 @@ func clientForResource(pool dynamic.ClientPool, disco discovery.DiscoveryInterfa
 		return nil, err
 	}
 
-	namespace := obj.GetNamespace()
+	meta, err := meta.Accessor(obj)
+	if err != nil {
+		return nil, err
+	}
+	namespace := meta.GetNamespace()
 	if namespace == "" {
 		namespace = defNs
 	}
diff --git a/cmd/update.go b/cmd/update.go
index f5b5ff40..b3787edc 100644
--- a/cmd/update.go
+++ b/cmd/update.go
@@ -23,20 +23,47 @@ import (
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/diff"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/client-go/discovery"
+	"k8s.io/client-go/dynamic"
 
 	"github.com/ksonnet/kubecfg/utils"
 )
 
 const (
 	flagCreate = "create"
+	flagSkipGc = "skip-gc"
+	flagGcTag  = "gc-tag"
+	flagDryRun = "dry-run"
+
+	// AnnotationGcTag annotation that triggers
+	// garbage collection. Objects with value equal to
+	// command-line flag that are *not* in config will be deleted.
+	AnnotationGcTag = "kubecfg.ksonnet.io/garbage-collect-tag"
+
+	// AnnotationGcStrategy controls gc logic.  Current values:
+	// `auto` (default if absent) - do garbage collection
+	// `ignore` - never garbage collect this object
+	AnnotationGcStrategy = "kubecfg.ksonnet.io/garbage-collect-strategy"
+
+	// GcStrategyAuto is the default automatic gc logic
+	GcStrategyAuto = "auto"
+	// GcStrategyIgnore means this object should be ignored by garbage collection
+	GcStrategyIgnore = "ignore"
 )
 
 func init() {
 	RootCmd.AddCommand(updateCmd)
 	updateCmd.PersistentFlags().Bool(flagCreate, true, "Create missing resources")
+	updateCmd.PersistentFlags().Bool(flagSkipGc, false, "Don't perform garbage collection, even with --"+flagGcTag)
+	updateCmd.PersistentFlags().String(flagGcTag, "", "Add this tag to updated objects, and garbage collect existing objects with this tag and not in config")
+	updateCmd.PersistentFlags().Bool(flagDryRun, false, "Perform only read-only operations")
 }
 
 var updateCmd = &cobra.Command{
@@ -50,7 +77,17 @@ var updateCmd = &cobra.Command{
 			return err
 		}
 
-		objs, err := readObjs(cmd, args)
+		gcTag, err := flags.GetString(flagGcTag)
+		if err != nil {
+			return err
+		}
+
+		skipGc, err := flags.GetBool(flagSkipGc)
+		if err != nil {
+			return err
+		}
+
+		dryRun, err := flags.GetBool(flagDryRun)
 		if err != nil {
 			return err
 		}
@@ -65,13 +102,29 @@ var updateCmd = &cobra.Command{
 			return err
 		}
 
+		objs, err := readObjs(cmd, args)
+		if err != nil {
+			return err
+		}
+
+		dryRunText := ""
+		if dryRun {
+			dryRunText = " (dry-run)"
+		}
+
 		sort.Sort(utils.DependencyOrder(objs))
 
+		seenUids := sets.NewString()
+
 		for _, obj := range objs {
+			if gcTag != "" {
+				utils.SetMetaDataAnnotation(obj, AnnotationGcTag, gcTag)
+			}
+
 			desc := fmt.Sprintf("%s/%s", obj.GetKind(), fqName(obj))
-			log.Info("Updating ", desc)
+			log.Info("Updating ", desc, dryRunText)
 
-			c, err := clientForResource(clientpool, disco, obj, defaultNs)
+			rc, err := clientForResource(clientpool, disco, obj, defaultNs)
 			if err != nil {
 				return err
 			}
@@ -80,10 +133,22 @@ var updateCmd = &cobra.Command{
 			if err != nil {
 				return err
 			}
-			newobj, err := c.Patch(obj.GetName(), types.MergePatchType, asPatch)
+			var newobj metav1.Object
+			if !dryRun {
+				newobj, err = rc.Patch(obj.GetName(), types.MergePatchType, asPatch)
+				log.Debug("Patch(%s) returned (%v, %v)", obj.GetName(), newobj, err)
+			} else {
+				newobj, err = rc.Get(obj.GetName())
+			}
 			if create && errors.IsNotFound(err) {
-				log.Info(" Creating non-existent ", desc)
-				newobj, err = c.Create(obj)
+				log.Info(" Creating non-existent ", desc, dryRunText)
+				if !dryRun {
+					newobj, err = rc.Create(obj)
+					log.Debug("Create(%s) returned (%v, %v)", obj.GetName(), newobj, err)
+				} else {
+					newobj = obj
+					err = nil
+				}
 			}
 			if err != nil {
 				// TODO: retry
@@ -91,6 +156,43 @@ var updateCmd = &cobra.Command{
 			}
 
 			log.Debug("Updated object: ", diff.ObjectDiff(obj, newobj))
+
+			// Some objects appear under multiple kinds
+			// (eg: Deployment is both extensions/v1beta1
+			// and apps/v1beta1).  UID is the only stable
+			// identifier that links these two views of
+			// the same object.
+			seenUids.Insert(string(newobj.GetUID()))
+		}
+
+		if gcTag != "" && !skipGc {
+			version, err := utils.FetchVersion(disco)
+			if err != nil {
+				return err
+			}
+
+			err = walkObjects(clientpool, disco, metav1.ListOptions{}, func(o runtime.Object) error {
+				meta, err := meta.Accessor(o)
+				if err != nil {
+					return err
+				}
+				gvk := o.GetObjectKind().GroupVersionKind()
+				desc := fmt.Sprintf("%s/%s (%s)", gvk.Kind, fqName(meta), gvk.GroupVersion())
+				log.Debugf("Considering %v for gc", desc)
+				if eligibleForGc(meta, gcTag) && !seenUids.Has(string(meta.GetUID())) {
+					log.Info("Garbage collecting ", desc, dryRunText)
+					if !dryRun {
+						err := gcDelete(clientpool, disco, &version, o)
+						if err != nil {
+							return err
+						}
+					}
+				}
+				return nil
+			})
+			if err != nil {
+				return err
+			}
 		}
 
 		return nil
@@ -103,3 +205,114 @@ func fqName(o metav1.Object) string {
 	}
 	return fmt.Sprintf("%s.%s", o.GetNamespace(), o.GetName())
 }
+
+func stringListContains(list []string, value string) bool {
+	for _, item := range list {
+		if item == value {
+			return true
+		}
+	}
+	return false
+}
+
+func gcDelete(clientpool dynamic.ClientPool, disco discovery.DiscoveryInterface, version *utils.ServerVersion, o runtime.Object) error {
+	obj, err := meta.Accessor(o)
+	if err != nil {
+		return fmt.Errorf("Unexpected object type: %s", err)
+	}
+
+	uid := obj.GetUID()
+	desc := fmt.Sprintf("%s/%s", o.GetObjectKind().GroupVersionKind().Kind, fqName(obj))
+
+	deleteOpts := metav1.DeleteOptions{
+		Preconditions: &metav1.Preconditions{UID: &uid},
+	}
+	if version.Compare(1, 6) < 0 {
+		// 1.5.x option
+		boolFalse := false
+		deleteOpts.OrphanDependents = &boolFalse
+	} else {
+		// 1.6.x option (NB: Background is broken)
+		fg := metav1.DeletePropagationForeground
+		deleteOpts.PropagationPolicy = &fg
+	}
+
+	c, err := clientForResource(clientpool, disco, o, metav1.NamespaceNone)
+	if err != nil {
+		return err
+	}
+
+	err = c.Delete(obj.GetName(), &deleteOpts)
+	if err != nil && (errors.IsNotFound(err) || errors.IsConflict(err)) {
+		// We lost a race with something else changing the object
+		log.Debugf("Ignoring error while deleting %s: %s", desc, err)
+		err = nil
+	}
+	if err != nil {
+		return fmt.Errorf("Error deleting %s: %s", desc, err)
+	}
+
+	return nil
+}
+
+func walkObjects(pool dynamic.ClientPool, disco discovery.DiscoveryInterface, listopts metav1.ListOptions, callback func(runtime.Object) error) error {
+	rsrclists, err := disco.ServerResources()
+	if err != nil {
+		return err
+	}
+	for _, rsrclist := range rsrclists {
+		gv, err := schema.ParseGroupVersion(rsrclist.GroupVersion)
+		if err != nil {
+			return err
+		}
+		for _, rsrc := range rsrclist.APIResources {
+			gvk := gv.WithKind(rsrc.Kind)
+
+			if !stringListContains(rsrc.Verbs, "list") {
+				log.Debugf("Don't know how to list %v, skipping", rsrc)
+				continue
+			}
+			client, err := pool.ClientForGroupVersionKind(gvk)
+			if err != nil {
+				return err
+			}
+
+			var ns string
+			if rsrc.Namespaced {
+				ns = metav1.NamespaceAll
+			} else {
+				ns = metav1.NamespaceNone
+			}
+
+			rc := client.Resource(&rsrc, ns)
+			log.Debugf("Listing %s", gvk)
+			obj, err := rc.List(listopts)
+			if err != nil {
+				return err
+			}
+			if err = meta.EachListItem(obj, callback); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+func eligibleForGc(obj metav1.Object, gcTag string) bool {
+	for _, ref := range obj.GetOwnerReferences() {
+		if ref.Controller != nil && *ref.Controller {
+			// Has a controller ref
+			return false
+		}
+	}
+
+	a := obj.GetAnnotations()
+
+	strategy, ok := a[AnnotationGcStrategy]
+	if !ok {
+		strategy = GcStrategyAuto
+	}
+
+	return a[AnnotationGcTag] == gcTag &&
+		strategy == GcStrategyAuto
+}
diff --git a/cmd/update_test.go b/cmd/update_test.go
new file mode 100644
index 00000000..70d596a5
--- /dev/null
+++ b/cmd/update_test.go
@@ -0,0 +1,81 @@
+package cmd
+
+import (
+	"testing"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+	"github.com/ksonnet/kubecfg/utils"
+)
+
+func TestStringListContains(t *testing.T) {
+	foobar := []string{"foo", "bar"}
+	if stringListContains([]string{}, "") {
+		t.Error("Empty list was not empty")
+	}
+	if !stringListContains(foobar, "foo") {
+		t.Error("Failed to find foo")
+	}
+	if stringListContains(foobar, "baz") {
+		t.Error("Should not contain baz")
+	}
+}
+
+func TestEligibleForGc(t *testing.T) {
+	const myTag = "my-gctag"
+	boolTrue := true
+	o := &unstructured.Unstructured{
+		Object: map[string]interface{}{
+			"apiVersion": "tests/v1alpha1",
+			"kind":       "Dummy",
+		},
+	}
+
+	if eligibleForGc(o, myTag) {
+		t.Errorf("%v should not be eligible (no tag)", o)
+	}
+
+	utils.SetMetaDataAnnotation(o, AnnotationGcTag, "unknowntag")
+	if eligibleForGc(o, myTag) {
+		t.Errorf("%v should not be eligible (wrong tag)", o)
+	}
+
+	utils.SetMetaDataAnnotation(o, AnnotationGcTag, myTag)
+	if !eligibleForGc(o, myTag) {
+		t.Errorf("%v should be eligible", o)
+	}
+
+	utils.SetMetaDataAnnotation(o, AnnotationGcStrategy, GcStrategyIgnore)
+	if eligibleForGc(o, myTag) {
+		t.Errorf("%v should not be eligible (strategy=ignore)", o)
+	}
+
+	utils.SetMetaDataAnnotation(o, AnnotationGcStrategy, GcStrategyAuto)
+	if !eligibleForGc(o, myTag) {
+		t.Errorf("%v should be eligible (strategy=auto)", o)
+	}
+
+	// Unstructured.SetOwnerReferences is broken in apimachinery release-1.6
+	// See kubernetes/kubernetes#46817
+	setOwnerRef := func(u *unstructured.Unstructured, ref metav1.OwnerReference) {
+		// This is not a complete nor robust reimplementation
+		c := map[string]interface{}{
+			"kind": ref.Kind,
+			"name": ref.Name,
+		}
+		if ref.Controller != nil {
+			c["controller"] = *ref.Controller
+		}
+		u.Object["metadata"].(map[string]interface{})["ownerReferences"] = []map[string]interface{}{c}
+	}
+	setOwnerRef(o, metav1.OwnerReference{Kind: "foo", Name: "bar"})
+	if !eligibleForGc(o, myTag) {
+		t.Errorf("%v should be eligible (non-controller ownerref)", o)
+	}
+
+	setOwnerRef(o, metav1.OwnerReference{Kind: "foo", Name: "bar", Controller: &boolTrue})
+	if eligibleForGc(o, myTag) {
+		t.Errorf("%v should not be eligible (controller ownerref)", o)
+	}
+}
diff --git a/utils/meta.go b/utils/meta.go
index 9d448a44..8c83afdb 100644
--- a/utils/meta.go
+++ b/utils/meta.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"strconv"
 
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/version"
 	"k8s.io/client-go/discovery"
 )
@@ -60,3 +61,13 @@ func (v ServerVersion) Compare(major, minor int) int {
 func (v ServerVersion) String() string {
 	return fmt.Sprintf("%d.%d", v.Major, v.Minor)
 }
+
+// SetMetaDataAnnotation sets an annotation value
+func SetMetaDataAnnotation(obj metav1.Object, key, value string) {
+	a := obj.GetAnnotations()
+	if a == nil {
+		a = make(map[string]string)
+	}
+	a[key] = value
+	obj.SetAnnotations(a)
+}
-- 
GitLab