From 78117253e7b19aa75784d8185a0f643a2b4c0d5f Mon Sep 17 00:00:00 2001
From: bryanl <bryanliles@gmail.com>
Date: Tue, 3 Apr 2018 08:48:20 -0400
Subject: [PATCH] Convert delete to ksonnet action

Signed-off-by: bryanl <bryanliles@gmail.com>
---
 actions/actions.go                            |  17 +++
 actions/delete.go                             |  84 ++++++++++++
 actions/delete_test.go                        |  70 ++++++++++
 cmd/actions.go                                |   2 +-
 cmd/delete.go                                 |  61 ++++-----
 cmd/delete_test.go                            |  47 +++++++
 cmd/flags.go                                  |   1 +
 e2e/delete_test.go                            |  70 ++++++++++
 e2e/testdata/output/delete/output.txt         |   2 +
 .../output/env/targets/invalid-target.txt     |   2 +-
 e2e/validator.go                              |  47 ++++++-
 pkg/cluster/delete.go                         | 122 ++++++++++++++++++
 12 files changed, 482 insertions(+), 43 deletions(-)
 create mode 100644 actions/delete.go
 create mode 100644 actions/delete_test.go
 create mode 100644 cmd/delete_test.go
 create mode 100644 e2e/delete_test.go
 create mode 100644 e2e/testdata/output/delete/output.txt
 create mode 100644 pkg/cluster/delete.go

diff --git a/actions/actions.go b/actions/actions.go
index 2059cccf..757a3317 100644
--- a/actions/actions.go
+++ b/actions/actions.go
@@ -47,6 +47,8 @@ const (
 	OptionGcTag = "gc-tag"
 	// OptionGlobal is global option.
 	OptionGlobal = "global"
+	// OptionGracePeriod is gracePeriod option.
+	OptionGracePeriod = "grace-period"
 	// OptionIndex is index option. Is used to target individual items in multi object
 	// components.
 	OptionIndex = "index"
@@ -194,6 +196,21 @@ func (o *optionLoader) loadInt(name string) int {
 	return a
 }
 
+func (o *optionLoader) loadInt64(name string) int64 {
+	i := o.load(name)
+	if i == nil {
+		return 0
+	}
+
+	a, ok := i.(int64)
+	if !ok {
+		o.err = newInvalidOptionError(name)
+		return 0
+	}
+
+	return a
+}
+
 func (o *optionLoader) loadOptionalInt(name string) int {
 	i := o.loadOptional(name)
 	if i == nil {
diff --git a/actions/delete.go b/actions/delete.go
new file mode 100644
index 00000000..09aef807
--- /dev/null
+++ b/actions/delete.go
@@ -0,0 +1,84 @@
+// Copyright 2018 The ksonnet 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 actions
+
+import (
+	"github.com/ksonnet/ksonnet/client"
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/ksonnet/ksonnet/pkg/cluster"
+)
+
+type runDeleteFn func(cluster.DeleteConfig, ...cluster.DeleteOpts) error
+
+// RunDelete runs `delete`.
+func RunDelete(m map[string]interface{}) error {
+	a, err := newDelete(m)
+	if err != nil {
+		return err
+	}
+
+	return a.run()
+}
+
+type deleteOpt func(*Delete)
+
+// Delete collects options for applying objects to a cluster.
+type Delete struct {
+	app            app.App
+	clientConfig   *client.Config
+	componentNames []string
+	envName        string
+	gracePeriod    int64
+
+	runDeleteFn runDeleteFn
+}
+
+// RunDelete runs `apply`
+func newDelete(m map[string]interface{}, opts ...deleteOpt) (*Delete, error) {
+	ol := newOptionLoader(m)
+
+	a := &Delete{
+		app:            ol.loadApp(),
+		clientConfig:   ol.loadClientConfig(),
+		componentNames: ol.loadStringSlice(OptionComponentNames),
+		envName:        ol.loadString(OptionEnvName),
+		gracePeriod:    ol.loadInt64(OptionGracePeriod),
+
+		runDeleteFn: cluster.RunDelete,
+	}
+
+	if ol.err != nil {
+		return nil, ol.err
+	}
+
+	for _, opt := range opts {
+		opt(a)
+	}
+
+	return a, nil
+}
+
+func (a *Delete) run() error {
+	config := cluster.DeleteConfig{
+		App:            a.app,
+		ClientConfig:   a.clientConfig,
+		ComponentNames: a.componentNames,
+		EnvName:        a.envName,
+		GracePeriod:    a.gracePeriod,
+	}
+
+	return a.runDeleteFn(config)
+}
diff --git a/actions/delete_test.go b/actions/delete_test.go
new file mode 100644
index 00000000..95a055be
--- /dev/null
+++ b/actions/delete_test.go
@@ -0,0 +1,70 @@
+// Copyright 2018 The ksonnet 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 actions
+
+import (
+	"testing"
+
+	"github.com/ksonnet/ksonnet/client"
+	amocks "github.com/ksonnet/ksonnet/metadata/app/mocks"
+	"github.com/ksonnet/ksonnet/pkg/cluster"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDelete(t *testing.T) {
+	withApp(t, func(appMock *amocks.App) {
+		in := map[string]interface{}{
+			OptionApp:            appMock,
+			OptionClientConfig:   &client.Config{},
+			OptionComponentNames: []string{},
+			OptionEnvName:        "default",
+			OptionGracePeriod:    int64(3),
+		}
+
+		expected := cluster.DeleteConfig{
+			App:            appMock,
+			ClientConfig:   &client.Config{},
+			ComponentNames: []string{},
+			EnvName:        "default",
+			GracePeriod:    3,
+		}
+
+		runDeleteOpt := func(a *Delete) {
+			a.runDeleteFn = func(config cluster.DeleteConfig, opts ...cluster.DeleteOpts) error {
+				assert.Equal(t, expected, config)
+				return nil
+			}
+		}
+
+		a, err := newDelete(in, runDeleteOpt)
+		require.NoError(t, err)
+
+		err = a.run()
+		require.NoError(t, err)
+	})
+}
+
+func TestDelete_invalid_input(t *testing.T) {
+	withApp(t, func(appMock *amocks.App) {
+		in := map[string]interface{}{
+			OptionClientConfig: "invalid",
+		}
+
+		_, err := newDelete(in)
+		require.Error(t, err)
+	})
+}
diff --git a/cmd/actions.go b/cmd/actions.go
index 9bf577c9..eb61f744 100644
--- a/cmd/actions.go
+++ b/cmd/actions.go
@@ -65,7 +65,7 @@ var (
 		actionApply:         actions.RunApply,
 		actionComponentList: actions.RunComponentList,
 		actionComponentRm:   actions.RunComponentRm,
-		// actionDelete
+		actionDelete:        actions.RunDelete,
 		// actionDiff
 		actionEnvAdd:       actions.RunEnvAdd,
 		actionEnvDescribe:  actions.RunEnvDescribe,
diff --git a/cmd/delete.go b/cmd/delete.go
index d782a908..4a0f5061 100644
--- a/cmd/delete.go
+++ b/cmd/delete.go
@@ -17,30 +17,40 @@ package cmd
 
 import (
 	"fmt"
-	"os"
+
+	"github.com/spf13/viper"
 
 	"github.com/spf13/cobra"
 
+	"github.com/ksonnet/ksonnet/actions"
 	"github.com/ksonnet/ksonnet/client"
-	"github.com/ksonnet/ksonnet/pkg/kubecfg"
 )
 
 const (
-	flagGracePeriod = "grace-period"
 	deleteShortDesc = "Remove component-specified Kubernetes resources from remote clusters"
 )
 
+const (
+	vDeleteComponent   = "delete-components"
+	vDeleteGracePeriod = "delete-grace-period"
+)
+
 var (
 	deleteClientConfig *client.Config
 )
 
 func init() {
 	RootCmd.AddCommand(deleteCmd)
-	addEnvCmdFlags(deleteCmd)
+
 	deleteClientConfig = client.NewDefaultClientConfig()
 	deleteClientConfig.BindClientGoFlags(deleteCmd)
 	bindJsonnetFlags(deleteCmd)
-	deleteCmd.PersistentFlags().Int64(flagGracePeriod, -1, "Number of seconds given to resources to terminate gracefully. A negative value is ignored")
+
+	deleteCmd.Flags().StringSliceP(flagComponent, shortComponent, nil, "Name of a specific component (multiple -c flags accepted, allows YAML, JSON, and Jsonnet)")
+	viper.BindPFlag(vDeleteComponent, deleteCmd.Flags().Lookup(flagComponent))
+
+	deleteCmd.Flags().Int64(flagGracePeriod, -1, "Number of seconds given to resources to terminate gracefully. A negative value is ignored")
+	viper.BindPFlag(vDeleteGracePeriod, deleteCmd.Flags().Lookup(flagGracePeriod))
 }
 
 var deleteCmd = &cobra.Command{
@@ -50,43 +60,16 @@ var deleteCmd = &cobra.Command{
 		if len(args) != 1 {
 			return fmt.Errorf("'delete' requires an environment name; use `env list` to see available environments\n\n%s", cmd.UsageString())
 		}
-		env := args[0]
-
-		flags := cmd.Flags()
-		var err error
-
-		c := kubecfg.DeleteCmd{App: ka}
-
-		c.GracePeriod, err = flags.GetInt64(flagGracePeriod)
-		if err != nil {
-			return err
-		}
-
-		componentNames, err := flags.GetStringSlice(flagComponent)
-		if err != nil {
-			return err
-		}
-
-		cwd, err := os.Getwd()
-		if err != nil {
-			return err
-		}
 
-		c.ClientConfig = deleteClientConfig
-		c.Env = env
-
-		te := newCmdObjExpander(cmdObjExpanderConfig{
-			cmd:        cmd,
-			env:        env,
-			components: componentNames,
-			cwd:        cwd,
-		})
-		objs, err := te.Expand()
-		if err != nil {
-			return err
+		m := map[string]interface{}{
+			actions.OptionApp:            ka,
+			actions.OptionClientConfig:   deleteClientConfig,
+			actions.OptionComponentNames: viper.GetStringSlice(vDeleteComponent),
+			actions.OptionEnvName:        args[0],
+			actions.OptionGracePeriod:    viper.GetInt64(vDeleteGracePeriod),
 		}
 
-		return c.Run(objs)
+		return runAction(actionDelete, m)
 	},
 	Long: `
 The ` + "`delete`" + ` command removes Kubernetes resources (described in local
diff --git a/cmd/delete_test.go b/cmd/delete_test.go
new file mode 100644
index 00000000..3949e62a
--- /dev/null
+++ b/cmd/delete_test.go
@@ -0,0 +1,47 @@
+// Copyright 2018 The ksonnet 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 cmd
+
+import (
+	"testing"
+
+	"github.com/ksonnet/ksonnet/actions"
+)
+
+func Test_deleteCmd(t *testing.T) {
+	cases := []cmdTestCase{
+		{
+			name:   "with no options",
+			args:   []string{"delete", "default"},
+			action: actionDelete,
+			expected: map[string]interface{}{
+				actions.OptionApp:            nil,
+				actions.OptionEnvName:        "default",
+				actions.OptionComponentNames: make([]string, 0),
+				actions.OptionClientConfig:   deleteClientConfig,
+				actions.OptionGracePeriod:    int64(-1),
+			},
+		},
+		{
+			name:   "with no env",
+			args:   []string{"delete"},
+			action: actionDelete,
+			isErr:  true,
+		},
+	}
+
+	runTestCmd(t, cases)
+}
diff --git a/cmd/flags.go b/cmd/flags.go
index b907ba2f..6bdc625d 100644
--- a/cmd/flags.go
+++ b/cmd/flags.go
@@ -28,6 +28,7 @@ const (
 	flagExtVarFile            = "ext-str-file"
 	flagFilename              = "filename"
 	flagGcTag                 = "gc-tag"
+	flagGracePeriod           = "grace-period"
 	flagIndex                 = "index"
 	flagJpath                 = "jpath"
 	flagModule                = "module"
diff --git a/e2e/delete_test.go b/e2e/delete_test.go
new file mode 100644
index 00000000..5a2593f5
--- /dev/null
+++ b/e2e/delete_test.go
@@ -0,0 +1,70 @@
+// Copyright 2018 The ksonnet 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.
+
+// +build e2e
+
+package e2e
+
+import (
+	. "github.com/onsi/ginkgo"
+)
+
+var _ = Describe("ks delete", func() {
+	var a app
+	var namespace string
+	var o *output
+
+	BeforeEach(func() {
+		namespace = e.createNamespace()
+
+		io := &initOptions{
+			context:   "gke_bryan-heptio_us-central1-a_dev2",
+			namespace: namespace,
+		}
+
+		a = e.initApp(io)
+		a.generateDeployedService()
+
+		o := a.runKs("apply", "default")
+		assertExitStatus(o, 0)
+	})
+
+	AfterEach(func() {
+		e.removeNamespace(namespace)
+	})
+
+	Context("deleting all items in a module", func() {
+		JustBeforeEach(func() {
+			o = a.runKs("delete", "default")
+			assertExitStatus(o, 0)
+		})
+
+		It("reports which resources it deleting", func() {
+			assertExitStatus(o, 0)
+			assertOutput("delete/output.txt", o.stderr)
+		})
+
+		It("deletes guestbook-ui service", func() {
+			v := newValidator(e.restConfig, namespace)
+			v.hasNoService("guestbook-ui")
+		})
+
+		It("deletes guestbook-ui deployment", func() {
+			v := newValidator(e.restConfig, namespace)
+			v.hasNoDeployment("guestbook-ui")
+		})
+	})
+
+})
diff --git a/e2e/testdata/output/delete/output.txt b/e2e/testdata/output/delete/output.txt
new file mode 100644
index 00000000..fcb8378a
--- /dev/null
+++ b/e2e/testdata/output/delete/output.txt
@@ -0,0 +1,2 @@
+Deleting services guestbook-ui
+Deleting deployments guestbook-ui
diff --git a/e2e/testdata/output/env/targets/invalid-target.txt b/e2e/testdata/output/env/targets/invalid-target.txt
index a7e06fa3..37c4e34a 100644
--- a/e2e/testdata/output/env/targets/invalid-target.txt
+++ b/e2e/testdata/output/env/targets/invalid-target.txt
@@ -1 +1 @@
-unable to find namespace "bad"
+unable to find module "bad"
diff --git a/e2e/validator.go b/e2e/validator.go
index 9ba86f84..bb9d70c2 100644
--- a/e2e/validator.go
+++ b/e2e/validator.go
@@ -16,6 +16,10 @@
 package e2e
 
 import (
+	"time"
+
+	"github.com/cenkalti/backoff"
+	"github.com/pkg/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
 	extv1beta1 "k8s.io/client-go/kubernetes/typed/extensions/v1beta1"
@@ -44,10 +48,49 @@ func (v *validator) hasService(name string) {
 	ExpectWithOffset(1, err).NotTo(HaveOccurred())
 }
 
+func (v *validator) hasNoService(name string) {
+	c, err := corev1.NewForConfig(v.config)
+	ExpectWithOffset(1, err).NotTo(HaveOccurred())
+
+	operation := func() error {
+		_, err = c.Services(v.namespace).Get(name, metav1.GetOptions{})
+		if err == nil {
+			return errors.Errorf("expected service %s to not exist", name)
+		}
+		return nil
+	}
+
+	bo := backoff.NewExponentialBackOff()
+	bo.MaxElapsedTime = 30 * time.Second
+
+	err = backoff.Retry(operation, bo)
+	ExpectWithOffset(1, err).ToNot(HaveOccurred())
+}
+
 func (v *validator) hasDeployment(name string) {
 	c, err := extv1beta1.NewForConfig(v.config)
-	Expect(err).NotTo(HaveOccurred())
+	ExpectWithOffset(1, err).NotTo(HaveOccurred())
 
 	_, err = c.Deployments(v.namespace).Get(name, metav1.GetOptions{})
-	Expect(err).NotTo(HaveOccurred())
+	ExpectWithOffset(1, err).NotTo(HaveOccurred())
+}
+
+func (v *validator) hasNoDeployment(name string) {
+	c, err := extv1beta1.NewForConfig(v.config)
+	ExpectWithOffset(1, err).NotTo(HaveOccurred())
+
+	operation := func() error {
+		_, err = c.Deployments(v.namespace).Get(name, metav1.GetOptions{})
+		if err == nil {
+			return errors.Errorf("expected deployment %s to not exist", name)
+		}
+		return nil
+	}
+
+	bo := backoff.NewExponentialBackOff()
+	bo.MaxElapsedTime = 1 * time.Minute
+	bo.MaxInterval = 500 * time.Millisecond
+
+	err = backoff.Retry(operation, bo)
+	ExpectWithOffset(1, err).ToNot(HaveOccurred())
 }
diff --git a/pkg/cluster/delete.go b/pkg/cluster/delete.go
new file mode 100644
index 00000000..6e1866dc
--- /dev/null
+++ b/pkg/cluster/delete.go
@@ -0,0 +1,122 @@
+// Copyright 2018 The ksonnet 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 cluster
+
+import (
+	"fmt"
+	"sort"
+
+	"github.com/ksonnet/ksonnet/client"
+	"github.com/ksonnet/ksonnet/metadata/app"
+	"github.com/ksonnet/ksonnet/utils"
+	"github.com/pkg/errors"
+	log "github.com/sirupsen/logrus"
+	kerrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// DeleteConfig is configuration for Delete.
+type DeleteConfig struct {
+	App            app.App
+	ClientConfig   *client.Config
+	ComponentNames []string
+	EnvName        string
+	GracePeriod    int64
+}
+
+// DeleteOpts is an option for configuring Delete.
+type DeleteOpts func(*Delete)
+
+// Delete deletes objects from the cluster.
+type Delete struct {
+	DeleteConfig
+
+	// these make it easier to test Delete.
+	findObjectsFn         findObjectsFn
+	genClientOptsFn       genClientOptsFn
+	objectInfo            ObjectInfo
+	resourceClientFactory resourceClientFactoryFn
+}
+
+// RunDelete runs delete against a cluster for a given configuration.
+func RunDelete(config DeleteConfig, opts ...DeleteOpts) error {
+	d := &Delete{
+		DeleteConfig:          config,
+		findObjectsFn:         findObjects,
+		genClientOptsFn:       genClientOpts,
+		resourceClientFactory: resourceClientFactory,
+		objectInfo:            &objectInfo{},
+	}
+
+	for _, opt := range opts {
+		opt(d)
+	}
+
+	return d.Delete()
+}
+
+// Delete deletes objects from a cluster.
+func (d *Delete) Delete() error {
+	apiObjects, err := d.findObjectsFn(d.App, d.EnvName, d.ComponentNames)
+	if err != nil {
+		return errors.Wrap(err, "find objects")
+	}
+
+	co, err := d.genClientOptsFn(d.App, d.ClientConfig, d.EnvName)
+	if err != nil {
+		return err
+	}
+
+	version, err := utils.FetchVersion(co.discovery)
+	if err != nil {
+		return err
+	}
+	sort.Sort(sort.Reverse(utils.DependencyOrder(apiObjects)))
+
+	deleteOpts := metav1.DeleteOptions{}
+	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
+	}
+	if d.GracePeriod >= 0 {
+		deleteOpts.GracePeriodSeconds = &d.GracePeriod
+	}
+
+	for _, obj := range apiObjects {
+		desc := fmt.Sprintf("%s %s", d.objectInfo.ResourceName(co.discovery, obj), utils.FqName(obj))
+		log.Info("Deleting ", desc)
+
+		client, err := d.resourceClientFactory(co, obj)
+		if err != nil {
+			return err
+		}
+
+		err = client.Delete(&deleteOpts)
+		if err != nil && !kerrors.IsNotFound(err) {
+			return fmt.Errorf("Error deleting %s: %s", desc, err)
+		}
+
+		log.Debugf("Deleted object: ", obj)
+	}
+
+	return nil
+
+}
-- 
GitLab