Repository: grafana/tanka (go)

Issue:
Feature request: add --dry-run option
Add `--dry-run={server,client}` option (passed down to `kubectl` invocation), useful to have a concise view of resources and changes involved, specially if using `--dry-run=server`.



Pull request description:
feature: add --dry-run kubectl option
Fixes #666.

* add --dry-run kubectl option to `apply`,
  `delete`, `prune`
* add some simple testing to pkg/kubernetes/client


Patch:
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index f36546c4..bc19a06e 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -21,6 +21,14 @@ const (
 	ExitStatusDiff = 16
 )
 
+func validateDryRun(dryRunStr string) error {
+	switch dryRunStr {
+	case "", "none", "client", "server":
+		return nil
+	}
+	return fmt.Errorf(`--dry-run must be either: "", "none", "server" or "client"`)
+}
+
 func applyCmd() *cli.Command {
 	cmd := &cli.Command{
 		Use:   "apply <path>",
@@ -32,11 +40,17 @@ func applyCmd() *cli.Command {
 	cmd.Flags().BoolVar(&opts.Force, "force", false, "force applying (kubectl apply --force)")
 	cmd.Flags().BoolVar(&opts.Validate, "validate", true, "validation of resources (kubectl --validate=false)")
 	cmd.Flags().BoolVar(&opts.AutoApprove, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
+	cmd.Flags().StringVar(&opts.DryRun, "dry-run", "", `--dry-run parameter to pass down to kubectl, must be "none", "server", or "client"`)
 
 	vars := workflowFlags(cmd.Flags())
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
+		err := validateDryRun(opts.DryRun)
+		if err != nil {
+			return err
+		}
+
 		filters, err := process.StrExps(vars.targets...)
 		if err != nil {
 			return err
@@ -58,12 +72,18 @@ func pruneCmd() *cli.Command {
 	}
 
 	var opts tanka.PruneOpts
+	cmd.Flags().StringVar(&opts.DryRun, "dry-run", "", `--dry-run parameter to pass down to kubectl, must be "none", "server", or "client"`)
 	cmd.Flags().BoolVar(&opts.Force, "force", false, "force deleting (kubectl delete --force)")
 	cmd.Flags().BoolVar(&opts.AutoApprove, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
 	cmd.Flags().StringVar(&opts.Name, "name", "", "string that only a single inline environment contains in its name")
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
+		err := validateDryRun(opts.DryRun)
+		if err != nil {
+			return err
+		}
+
 		opts.JsonnetOpts = getJsonnetOpts()
 
 		return tanka.Prune(args[0], opts)
@@ -80,6 +100,7 @@ func deleteCmd() *cli.Command {
 	}
 
 	var opts tanka.DeleteOpts
+	cmd.Flags().StringVar(&opts.DryRun, "dry-run", "", `--dry-run parameter to pass down to kubectl, must be "none", "server", or "client"`)
 	cmd.Flags().BoolVar(&opts.Force, "force", false, "force deleting (kubectl delete --force)")
 	cmd.Flags().BoolVar(&opts.Validate, "validate", true, "validation of resources (kubectl --validate=false)")
 	cmd.Flags().BoolVar(&opts.AutoApprove, "dangerous-auto-approve", false, "skip interactive approval. Only for automation!")
@@ -88,6 +109,11 @@ func deleteCmd() *cli.Command {
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
+		err := validateDryRun(opts.DryRun)
+		if err != nil {
+			return err
+		}
+
 		filters, err := process.StrExps(vars.targets...)
 		if err != nil {
 			return err
diff --git a/pkg/kubernetes/client/apply.go b/pkg/kubernetes/client/apply.go
index a166d84c..83a40cb2 100644
--- a/pkg/kubernetes/client/apply.go
+++ b/pkg/kubernetes/client/apply.go
@@ -1,14 +1,16 @@
 package client
 
 import (
+	"fmt"
 	"os"
+	"os/exec"
 	"strings"
 
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
 )
 
-// Apply applies the given yaml to the cluster
-func (k Kubectl) Apply(data manifest.List, opts ApplyOpts) error {
+// Test-ability: isolate applyCtl to build and return exec.Cmd from ApplyOpts
+func (k Kubectl) applyCtl(data manifest.List, opts ApplyOpts) *exec.Cmd {
 	argv := []string{"-f", "-"}
 	if opts.Force {
 		argv = append(argv, "--force")
@@ -18,7 +20,18 @@ func (k Kubectl) Apply(data manifest.List, opts ApplyOpts) error {
 		argv = append(argv, "--validate=false")
 	}
 
-	cmd := k.ctl("apply", argv...)
+	if opts.DryRun != "" {
+		dryRun := fmt.Sprintf("--dry-run=%s", opts.DryRun)
+		argv = append(argv, dryRun)
+	}
+
+	return k.ctl("apply", argv...)
+}
+
+// Apply applies the given yaml to the cluster
+func (k Kubectl) Apply(data manifest.List, opts ApplyOpts) error {
+	cmd := k.applyCtl(data, opts)
+
 	cmd.Stdout = os.Stdout
 	cmd.Stderr = os.Stderr
 
diff --git a/pkg/kubernetes/client/client.go b/pkg/kubernetes/client/client.go
index 28bec6ec..7b88cef6 100644
--- a/pkg/kubernetes/client/client.go
+++ b/pkg/kubernetes/client/client.go
@@ -50,6 +50,9 @@ type ApplyOpts struct {
 
 	// autoApprove allows to skip the interactive approval
 	AutoApprove bool
+
+	// DryRun string passed to kubectl as --dry-run=<DryRun>
+	DryRun string
 }
 
 // DeleteOpts allow to specify additional parameters for delete operations
diff --git a/pkg/kubernetes/client/delete.go b/pkg/kubernetes/client/delete.go
index 844f49b0..7a827be3 100644
--- a/pkg/kubernetes/client/delete.go
+++ b/pkg/kubernetes/client/delete.go
@@ -2,11 +2,14 @@ package client
 
 import (
 	"bytes"
+	"fmt"
 	"os"
+	"os/exec"
 	"strings"
 )
 
-func (k Kubectl) Delete(namespace, kind, name string, opts DeleteOpts) error {
+// Test-ability: isolate deleteCtl to build and return exec.Cmd from DeleteOpts
+func (k Kubectl) deleteCtl(namespace, kind, name string, opts DeleteOpts) *exec.Cmd {
 	argv := []string{
 		"-n", namespace,
 		kind, name,
@@ -15,7 +18,18 @@ func (k Kubectl) Delete(namespace, kind, name string, opts DeleteOpts) error {
 		argv = append(argv, "--force")
 	}
 
-	cmd := k.ctl("delete", argv...)
+	if opts.DryRun != "" {
+		dryRun := fmt.Sprintf("--dry-run=%s", opts.DryRun)
+		argv = append(argv, dryRun)
+	}
+
+	return k.ctl("delete", argv...)
+}
+
+// Delete deletes the given Kubernetes resource from the cluster
+func (k Kubectl) Delete(namespace, kind, name string, opts DeleteOpts) error {
+
+	cmd := k.deleteCtl(namespace, kind, name, opts)
 
 	var stdout bytes.Buffer
 	var stderr bytes.Buffer
@@ -31,6 +45,9 @@ func (k Kubectl) Delete(namespace, kind, name string, opts DeleteOpts) error {
 		}
 		return err
 	}
+	if opts.DryRun != "" {
+		print(stdout.String())
+	}
 
 	return nil
 }
diff --git a/pkg/tanka/prune.go b/pkg/tanka/prune.go
index 78b8a34c..be45b7b2 100644
--- a/pkg/tanka/prune.go
+++ b/pkg/tanka/prune.go
@@ -18,6 +18,8 @@ type PruneOpts struct {
 	AutoApprove bool
 	// Force ignores any warnings kubectl might have
 	Force bool
+	// DryRun string passed to kubectl as --dry-run=<DryRun>
+	DryRun string
 }
 
 // Prune deletes all resources from the cluster, that are no longer present in
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index e0e305d6..6d6e3fa3 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -25,6 +25,8 @@ type ApplyOpts struct {
 	Force bool
 	// Validate set to false ignores invalid Kubernetes schemas
 	Validate bool
+	// DryRun string passed to kubectl as --dry-run=<DryRun>
+	DryRun string
 }
 
 // Apply parses the environment at the given directory (a `baseDir`) and applies
@@ -59,7 +61,7 @@ func Apply(baseDir string, opts ApplyOpts) error {
 	}
 
 	// prompt for confirmation
-	if opts.AutoApprove {
+	if opts.AutoApprove || opts.DryRun != "" {
 	} else if err := confirmPrompt("Applying to", l.Env.Spec.Namespace, kube.Info()); err != nil {
 		return err
 	}
@@ -67,6 +69,7 @@ func Apply(baseDir string, opts ApplyOpts) error {
 	return kube.Apply(l.Resources, kubernetes.ApplyOpts{
 		Force:    opts.Force,
 		Validate: opts.Validate,
+		DryRun:   opts.DryRun,
 	})
 }
 
@@ -134,6 +137,8 @@ type DeleteOpts struct {
 	Force bool
 	// Validate set to false ignores invalid Kubernetes schemas
 	Validate bool
+	// DryRun string passed to kubectl as --dry-run=<DryRun>
+	DryRun string
 }
 
 // Delete parses the environment at the given directory (a `baseDir`) and deletes
@@ -150,22 +155,24 @@ func Delete(baseDir string, opts DeleteOpts) error {
 	}
 	defer kube.Close()
 
-	// show diff
-	// static differ will never fail and always return something if input is not nil
-	diff, err := kubernetes.StaticDiffer(false)(l.Resources)
+	if opts.DryRun == "" {
+		// show diff
+		// static differ will never fail and always return something if input is not nil
+		diff, err := kubernetes.StaticDiffer(false)(l.Resources)
 
-	if err != nil {
-		fmt.Println("Error diffing:", err)
-	}
+		if err != nil {
+			fmt.Println("Error diffing:", err)
+		}
 
-	// in case of non-fatal error diff may be nil
-	if diff != nil {
-		b := term.Colordiff(*diff)
-		fmt.Print(b.String())
+		// in case of non-fatal error diff may be nil
+		if diff != nil {
+			b := term.Colordiff(*diff)
+			fmt.Print(b.String())
+		}
 	}
 
 	// prompt for confirmation
-	if opts.AutoApprove {
+	if opts.AutoApprove || opts.DryRun != "" {
 	} else if err := confirmPrompt("Deleting from", l.Env.Spec.Namespace, kube.Info()); err != nil {
 		return err
 	}
@@ -173,6 +180,7 @@ func Delete(baseDir string, opts DeleteOpts) error {
 	return kube.Delete(l.Resources, kubernetes.DeleteOpts{
 		Force:    opts.Force,
 		Validate: opts.Validate,
+		DryRun:   opts.DryRun,
 	})
 }
 


Test patch:
diff --git a/pkg/kubernetes/client/apply_test.go b/pkg/kubernetes/client/apply_test.go
new file mode 100644
index 00000000..193075b7
--- /dev/null
+++ b/pkg/kubernetes/client/apply_test.go
@@ -0,0 +1,79 @@
+package client
+
+import (
+	"testing"
+
+	"k8s.io/apimachinery/pkg/util/sets"
+
+	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+)
+
+func TestKubectl_applyCtl(t *testing.T) {
+	info := Info{
+		Kubeconfig: Config{
+			Context: Context{
+				Name: "foo-context",
+			},
+		},
+	}
+
+	type args struct {
+		data manifest.List
+		opts ApplyOpts
+	}
+	tests := []struct {
+		name           string
+		args           args
+		expectedArgs   []string
+		unExpectedArgs []string
+	}{
+		{
+			name: "test default",
+			args: args{
+				opts: ApplyOpts{Validate: true},
+			},
+			expectedArgs:   []string{"--context", info.Kubeconfig.Context.Name},
+			unExpectedArgs: []string{"--force", "--dry-run=server", "--validate=false"},
+		},
+		{
+			name: "test force",
+			args: args{
+				opts: ApplyOpts{Validate: true, Force: true},
+			},
+			expectedArgs:   []string{"--force"},
+			unExpectedArgs: []string{"--validate=false"},
+		},
+		{
+			name: "test validate",
+			args: args{
+				opts: ApplyOpts{Validate: false},
+			},
+			expectedArgs: []string{"--validate=false"},
+		},
+		{
+			name: "test dry-run",
+			args: args{
+				opts: ApplyOpts{Validate: true, DryRun: "server"},
+			},
+			expectedArgs:   []string{"--dry-run=server"},
+			unExpectedArgs: []string{"--validate=false"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			k := Kubectl{
+				info: info,
+			}
+			got := k.applyCtl(tt.args.data, tt.args.opts)
+			gotSet := sets.NewString(got.Args...)
+			if !gotSet.HasAll(tt.expectedArgs...) {
+				t.Errorf("Kubectl.applyCtl() = %v doesn't have (all) expectedArgs='%v'", got.Args, tt.expectedArgs)
+			}
+			if gotSet.HasAny(tt.unExpectedArgs...) {
+				t.Errorf("Kubectl.applyCtl() = %v has (any) unExpectedArgs='%v'", got.Args, tt.unExpectedArgs)
+			}
+
+		})
+	}
+}
diff --git a/pkg/kubernetes/client/delete_test.go b/pkg/kubernetes/client/delete_test.go
new file mode 100644
index 00000000..ddb9e5f7
--- /dev/null
+++ b/pkg/kubernetes/client/delete_test.go
@@ -0,0 +1,74 @@
+package client
+
+import (
+	"testing"
+
+	"k8s.io/apimachinery/pkg/util/sets"
+)
+
+func TestKubectl_deleteCtl(t *testing.T) {
+	info := Info{
+		Kubeconfig: Config{
+			Context: Context{
+				Name: "foo-context",
+			},
+		},
+	}
+
+	type args struct {
+		ns   string
+		kind string
+		name string
+		opts DeleteOpts
+	}
+
+	tests := []struct {
+		name           string
+		args           args
+		expectedArgs   []string
+		unExpectedArgs []string
+	}{
+		{
+			name: "test default",
+			args: args{
+				ns:   "foo-ns",
+				kind: "deploy",
+				name: "foo-deploy",
+				opts: DeleteOpts{},
+			},
+			expectedArgs:   []string{"--context", info.Kubeconfig.Context.Name, "-n", "foo-ns", "deploy", "foo-deploy"},
+			unExpectedArgs: []string{"--force", "--dry-run=server"},
+		},
+		{
+			name: "test dry-run",
+			args: args{
+				opts: DeleteOpts{DryRun: "server"},
+			},
+			expectedArgs: []string{"--dry-run=server"},
+		},
+		{
+			name: "test force",
+			args: args{
+				opts: DeleteOpts{Force: true},
+			},
+			expectedArgs: []string{"--force"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			k := Kubectl{
+				info: info,
+			}
+			got := k.deleteCtl(tt.args.ns, tt.args.kind, tt.args.name, tt.args.opts)
+			gotSet := sets.NewString(got.Args...)
+			if !gotSet.HasAll(tt.expectedArgs...) {
+				t.Errorf("Kubectl.applyCtl() = %v doesn't have (all) expectedArgs='%v'", got.Args, tt.expectedArgs)
+			}
+			if gotSet.HasAny(tt.unExpectedArgs...) {
+				t.Errorf("Kubectl.applyCtl() = %v has (any) unExpectedArgs='%v'", got.Args, tt.unExpectedArgs)
+			}
+
+		})
+	}
+}