From 6a6704fcc79d17a6c758bc9d0ef165d6da235fbb Mon Sep 17 00:00:00 2001
From: Jessica Yuen <im.jessicayuen@gmail.com>
Date: Thu, 2 Nov 2017 15:57:42 -0700
Subject: [PATCH] Introduce command: `ks param diff <env1> <env2>
 [--component]`

Pretty prints differences between the component parameters of two
environments.

A component flag is accepted to diff against a single component. By
default, the diff is performed against all components.
---
 cmd/param.go              |  40 ++++++++-
 metadata/environment.go   |   2 +-
 pkg/kubecfg/param.go      | 174 ++++++++++++++++++++++++++++++++++++++
 pkg/kubecfg/param_test.go |  84 ++++++++++++++++++
 4 files changed, 297 insertions(+), 3 deletions(-)
 create mode 100644 pkg/kubecfg/param_test.go

diff --git a/cmd/param.go b/cmd/param.go
index af41950c..cd24de27 100644
--- a/cmd/param.go
+++ b/cmd/param.go
@@ -24,7 +24,8 @@ import (
 )
 
 const (
-	flagParamEnv = "env"
+	flagParamEnv       = "env"
+	flagParamComponent = "component"
 )
 
 func init() {
@@ -32,9 +33,11 @@ func init() {
 
 	paramCmd.AddCommand(paramSetCmd)
 	paramCmd.AddCommand(paramListCmd)
+	paramCmd.AddCommand(paramDiffCmd)
 
 	paramSetCmd.PersistentFlags().String(flagParamEnv, "", "Specify environment to set parameters for")
 	paramListCmd.PersistentFlags().String(flagParamEnv, "", "Specify environment to list parameters for")
+	paramDiffCmd.PersistentFlags().String(flagParamComponent, "", "Specify the component to diff against")
 }
 
 var paramCmd = &cobra.Command{
@@ -97,7 +100,7 @@ of environment parameters, we suggest modifying the
 }
 
 var paramListCmd = &cobra.Command{
-	Use:   "list [component-name]",
+	Use:   "list <component-name>",
 	Short: "List all parameters for a component(s)",
 	RunE: func(cmd *cobra.Command, args []string) error {
 		flags := cmd.Flags()
@@ -138,3 +141,36 @@ Furthermore, parameters can be listed on a per-environment basis.
   # List all parameters for the component "guestbook" in the environment "dev"
   ks param list guestbook --env=dev`,
 }
+
+var paramDiffCmd = &cobra.Command{
+	Use:   "diff <env1> <env2>",
+	Short: "Display differences between the component parameters of two environments",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		flags := cmd.Flags()
+		if len(args) != 2 {
+			return fmt.Errorf("'param diff' takes exactly two arguments, that is the name of the environments to diff against")
+		}
+
+		env1 := args[0]
+		env2 := args[1]
+
+		component, err := flags.GetString(flagParamComponent)
+		if err != nil {
+			return err
+		}
+
+		c := kubecfg.NewParamDiffCmd(env1, env2, component)
+
+		return c.Run(cmd.OutOrStdout())
+	},
+	Long: `"Pretty prints differences between the component parameters of two environments.
+
+A component flag is accepted to diff against a single component. By default, the
+diff is performed against all components.
+`,
+	Example: `  # Diff between the component parameters on environments 'dev' and 'prod'
+  ks param diff dev prod
+
+  # Diff between the component 'guestbook' on environments 'dev' and 'prod'
+  ks param diff dev prod --component=guestbook`,
+}
diff --git a/metadata/environment.go b/metadata/environment.go
index abebb2e2..29eb82af 100644
--- a/metadata/environment.go
+++ b/metadata/environment.go
@@ -206,7 +206,7 @@ func (m *manager) DeleteEnvironment(name string) error {
 func (m *manager) GetEnvironments() ([]*Environment, error) {
 	envs := []*Environment{}
 
-	log.Info("Retrieving all environments")
+	log.Debug("Retrieving all environments")
 	err := afero.Walk(m.appFS, string(m.environmentsPath), func(path string, f os.FileInfo, err error) error {
 		isDir, err := afero.IsDir(m.appFS, path)
 		if err != nil {
diff --git a/pkg/kubecfg/param.go b/pkg/kubecfg/param.go
index 3ddfb07d..e269607b 100644
--- a/pkg/kubecfg/param.go
+++ b/pkg/kubecfg/param.go
@@ -18,12 +18,14 @@ package kubecfg
 import (
 	"fmt"
 	"io"
+	"reflect"
 	"sort"
 	"strconv"
 	"strings"
 
 	param "github.com/ksonnet/ksonnet/metadata/params"
 
+	"github.com/fatih/color"
 	log "github.com/sirupsen/logrus"
 )
 
@@ -240,3 +242,175 @@ func outputParams(params map[string]param.Params, out io.Writer) error {
 	_, err := fmt.Fprint(out, strings.Join(lines, ""))
 	return err
 }
+
+// ----------------------------------------------------------------------------
+
+// ParamDiffCmd stores the information necessary to diff between environment
+// parameters.
+type ParamDiffCmd struct {
+	env1 string
+	env2 string
+
+	component string
+}
+
+// NewParamDiffCmd acts as a constructor for ParamDiffCmd.
+func NewParamDiffCmd(env1, env2, component string) *ParamDiffCmd {
+	return &ParamDiffCmd{env1: env1, env2: env2, component: component}
+}
+
+type paramDiffRecord struct {
+	component string
+	param     string
+	value1    string
+	value2    string
+}
+
+// Run executes the diffing of environment params.
+func (c *ParamDiffCmd) Run(out io.Writer) error {
+	manager, err := manager()
+	if err != nil {
+		return err
+	}
+
+	params1, err := manager.GetEnvironmentParams(c.env1)
+	if err != nil {
+		return err
+	}
+
+	params2, err := manager.GetEnvironmentParams(c.env2)
+	if err != nil {
+		return err
+	}
+
+	if len(c.component) != 0 {
+		params1 = map[string]param.Params{c.component: params1[c.component]}
+		params2 = map[string]param.Params{c.component: params2[c.component]}
+	}
+
+	if reflect.DeepEqual(params1, params2) {
+		log.Info("No differences found.")
+		return nil
+	}
+
+	records := diffParams(params1, params2)
+
+	//
+	// Format each component parameter information for pretty printing.
+	// Each component will be outputted alphabetically like the following:
+	//
+	//   COMPONENT PARAM     dev       prod
+	//   bar       name      "bar-dev" "bar"
+	//   foo       replicas  1
+	//
+
+	maxComponentLen := len(paramComponentHeader)
+	for _, k := range records {
+		if l := len(k.component); l > maxComponentLen {
+			maxComponentLen = l
+		}
+	}
+
+	maxParamLen := len(paramNameHeader) + maxComponentLen + 1
+	for _, k := range records {
+		if l := len(k.param) + maxComponentLen + 1; l > maxParamLen {
+			maxParamLen = l
+		}
+	}
+
+	maxEnvLen := len(c.env1) + maxParamLen + 1
+	for _, k := range records {
+		if l := len(k.value1) + maxParamLen + 1; l > maxEnvLen {
+			maxEnvLen = l
+		}
+	}
+
+	componentSpacing := strings.Repeat(" ", maxComponentLen-len(paramComponentHeader)+1)
+	nameSpacing := strings.Repeat(" ", maxParamLen-maxComponentLen-len(paramNameHeader))
+	envSpacing := strings.Repeat(" ", maxEnvLen-maxParamLen-len(c.env1))
+
+	// print headers
+	color.New(color.FgBlack).Fprintln(out, paramComponentHeader+componentSpacing+
+		paramNameHeader+nameSpacing+c.env1+envSpacing+c.env2)
+	color.New(color.FgBlack).Fprintln(out, strings.Repeat("=", len(paramComponentHeader))+componentSpacing+
+		strings.Repeat("=", len(paramNameHeader))+nameSpacing+
+		strings.Repeat("=", len(c.env1))+envSpacing+
+		strings.Repeat("=", len(c.env2)))
+
+	// print body
+	for _, k := range records {
+		componentSpacing = strings.Repeat(" ", maxComponentLen-len(k.component)+1)
+		nameSpacing = strings.Repeat(" ", maxParamLen-maxComponentLen-len(k.param))
+		envSpacing = strings.Repeat(" ", maxEnvLen-maxParamLen-len(k.value1))
+		line := fmt.Sprint(k.component + componentSpacing + k.param + nameSpacing + k.value1 + envSpacing + k.value2)
+		if len(k.value1) == 0 {
+			color.New(color.FgGreen).Fprintln(out, line)
+		} else if len(k.value2) == 0 {
+			color.New(color.FgRed).Fprintln(out, line)
+		} else if k.value1 != k.value2 {
+			color.New(color.FgYellow).Fprintln(out, line)
+		} else {
+			color.New(color.FgBlack).Fprintln(out, line)
+		}
+	}
+
+	return nil
+}
+
+func diffParams(params1, params2 map[string]param.Params) []*paramDiffRecord {
+	var records []*paramDiffRecord
+
+	for c := range params1 {
+		if _, contains := params2[c]; !contains {
+			// env2 doesn't have this component, add all params from env1 for this component
+			for p := range params2[c] {
+				records = addRecord(records, c, p, params1[c][p], "")
+			}
+		} else {
+			// has same component -- need to compare params
+			for p := range params1[c] {
+				if _, hasParam := params2[c][p]; !hasParam {
+					// env2 doesn't have this param, add a record with the param value from env1
+					records = addRecord(records, c, p, params1[c][p], "")
+				} else {
+					// env2 has this param too, add a record with both param values
+					records = addRecord(records, c, p, params1[c][p], params2[c][p])
+				}
+			}
+			// add remaining records for params that env2 has that env1 does not for this component
+			for p := range params2[c] {
+				if _, hasParam := params1[c][p]; !hasParam {
+					records = addRecord(records, c, p, "", params2[c][p])
+				}
+			}
+		}
+	}
+
+	// add remaining records where env2 contains a component that env1 does not
+	for c := range params2 {
+		if _, contains := params1[c]; !contains {
+			for p := range params2[c] {
+				records = addRecord(records, c, p, "", params2[c][p])
+			}
+		}
+	}
+
+	sort.Slice(records, func(i, j int) bool {
+		if records[i].component == records[j].component {
+			return records[i].param < records[j].param
+		}
+		return records[i].component < records[j].component
+	})
+
+	return records
+}
+
+func addRecord(records []*paramDiffRecord, component, param, value1, value2 string) []*paramDiffRecord {
+	records = append(records, &paramDiffRecord{
+		component: component,
+		param:     param,
+		value1:    value1,
+		value2:    value2,
+	})
+	return records
+}
diff --git a/pkg/kubecfg/param_test.go b/pkg/kubecfg/param_test.go
new file mode 100644
index 00000000..d3425bc1
--- /dev/null
+++ b/pkg/kubecfg/param_test.go
@@ -0,0 +1,84 @@
+// Copyright 2017 The kubecfg 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 kubecfg
+
+import (
+	"testing"
+
+	param "github.com/ksonnet/ksonnet/metadata/params"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestDiffParams(t *testing.T) {
+	tests := []struct {
+		params1  map[string]param.Params
+		params2  map[string]param.Params
+		expected []*paramDiffRecord
+	}{
+		{
+			map[string]param.Params{
+				"bar": param.Params{"replicas": "4"},
+				"foo": param.Params{"replicas": "1", "name": `"foo"`},
+			},
+			map[string]param.Params{
+				"bar": param.Params{"replicas": "3"},
+				"foo": param.Params{"name": `"foo-dev"`, "replicas": "1"},
+				"baz": param.Params{"name": `"baz"`, "replicas": "4"},
+			},
+			[]*paramDiffRecord{
+				&paramDiffRecord{
+					component: "bar",
+					param:     "replicas",
+					value1:    "4",
+					value2:    "3",
+				},
+				&paramDiffRecord{
+					component: "baz",
+					param:     "name",
+					value1:    "",
+					value2:    `"baz"`,
+				},
+				&paramDiffRecord{
+					component: "baz",
+					param:     "replicas",
+					value1:    "",
+					value2:    "4",
+				},
+				&paramDiffRecord{
+					component: "foo",
+					param:     "name",
+					value1:    `"foo"`,
+					value2:    `"foo-dev"`,
+				},
+				&paramDiffRecord{
+					component: "foo",
+					param:     "replicas",
+					value1:    "1",
+					value2:    "1",
+				},
+			},
+		},
+	}
+
+	for _, s := range tests {
+		records := diffParams(s.params1, s.params2)
+		require.Equal(t, len(records), len(s.expected), "Record lengths not equivalent")
+		for i, record := range records {
+			require.EqualValues(t, *s.expected[i], *record)
+		}
+	}
+}
-- 
GitLab