Repository: grafana/tanka (go)

Issue:
feature request: support forcing color diff output on/off
Tanka commands that output diffs (diff, apply, prune) only colorize that output if stdout is a terminal.  While this is a useful default for ensuring control codes don't end up in log files, unfortunately it also prevents color diffs for services that understand them such as [Gitlab Pipelines](https://docs.gitlab.com/ee/ci/yaml/script.html#add-color-codes-to-script-output).

GNU tools solve this by having a `--color` flag that can force color output on or off regardless of whether the output FD is a terminal or not. A good example of this is [grep](https://man7.org/linux/man-pages/man1/grep.1.html#:~:text=matching%20lines.%0A%0A%20%20%20%20%20%20%20%2D%2D-,color,-%5B%3DWHEN), but there are others.

It would be great if tk had this as well. Fortunately, the underlying color implementation already [supports this](https://github.com/fatih/color/issues/179), it just needs to be wired in.

Pull request description:
Support forcing color output on/off
Closes #798

Introduce flag `--color` to control the colorization of diff output.

Add flag to all commands that can output colorized diff text:
- `diff`
- `apply`
- `prune`

`--color` has 3 allowed values:
- `auto` (default): colorize if stdout is a terminal (current behavior)
- `always`: always colorize output
- `never`: never colorize output

Example: `--color=always`
![tk-diff-color-always](https://user-images.githubusercontent.com/41255671/210906777-080f977e-ddd5-4ddd-9a24-aee4b40ccd47.png)

Example: `--color=never`
![tk-diff-color-never](https://user-images.githubusercontent.com/41255671/210906839-a6079326-9ab4-4a14-bd9f-93fd38937739.png)


Patch:
diff --git a/cmd/tk/flags.go b/cmd/tk/flags.go
index a9384a74..79c32df8 100644
--- a/cmd/tk/flags.go
+++ b/cmd/tk/flags.go
@@ -24,6 +24,10 @@ func workflowFlags(fs *pflag.FlagSet) *workflowFlagVars {
 	return &v
 }
 
+func addDiffFlags(fs *pflag.FlagSet, opts *tanka.DiffBaseOpts) {
+	fs.StringVar(&opts.Color, "color", "auto", `controls color in diff output, must be "auto", "always", or "never"`)
+}
+
 func addApplyFlags(fs *pflag.FlagSet, opts *tanka.ApplyBaseOpts, autoApproveDeprecated *bool, autoApprove *string) {
 	fs.StringVar(&opts.DryRun, "dry-run", "", `--dry-run parameter to pass down to kubectl, must be "none", "server", or "client"`)
 	fs.BoolVar(&opts.Force, "force", false, "force applying (kubectl apply --force)")
diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go
index cf3db63b..b2553a58 100644
--- a/cmd/tk/workflow.go
+++ b/cmd/tk/workflow.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 
+	"github.com/fatih/color"
 	"github.com/go-clix/cli"
 	"github.com/posener/complete"
 
@@ -20,6 +21,24 @@ const (
 	ExitStatusDiff = 16
 )
 
+var (
+	colorValues = cli.PredictSet("auto", "always", "never")
+)
+
+func setForceColor(opts *tanka.DiffBaseOpts) error {
+	switch opts.Color {
+	case "":
+	case "auto":
+	case "always":
+		color.NoColor = false
+	case "never":
+		color.NoColor = true
+	default:
+		return fmt.Errorf(`--color must be either: "auto", "always", or "never"`)
+	}
+	return nil
+}
+
 func validateDryRun(dryRunStr string) error {
 	switch dryRunStr {
 	case "", "none", "client", "server":
@@ -56,6 +75,7 @@ func applyCmd() *cli.Command {
 		Short: "apply the configuration to the cluster",
 		Args:  workflowArgs,
 		Predictors: complete.Flags{
+			"color":          colorValues,
 			"diff-strategy":  cli.PredictSet("native", "subset", "validate", "server", "none"),
 			"apply-strategy": cli.PredictSet("client", "server"),
 		},
@@ -71,6 +91,7 @@ func applyCmd() *cli.Command {
 		autoApproveString     string
 	)
 	addApplyFlags(cmd.Flags(), &opts.ApplyBaseOpts, &autoApproveDeprecated, &autoApproveString)
+	addDiffFlags(cmd.Flags(), &opts.DiffBaseOpts)
 	vars := workflowFlags(cmd.Flags())
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
@@ -82,6 +103,9 @@ func applyCmd() *cli.Command {
 		if opts.AutoApprove, err = validateAutoApprove(autoApproveDeprecated, autoApproveString); err != nil {
 			return err
 		}
+		if err := setForceColor(&opts.DiffBaseOpts); err != nil {
+			return err
+		}
 
 		filters, err := process.StrExps(vars.targets...)
 		if err != nil {
@@ -101,6 +125,9 @@ func pruneCmd() *cli.Command {
 		Use:   "prune <path>",
 		Short: "delete resources removed from Jsonnet",
 		Args:  workflowArgs,
+		Predictors: complete.Flags{
+			"color": colorValues,
+		},
 	}
 
 	var opts tanka.PruneOpts
@@ -110,6 +137,7 @@ func pruneCmd() *cli.Command {
 		autoApproveString     string
 	)
 	addApplyFlags(cmd.Flags(), &opts.ApplyBaseOpts, &autoApproveDeprecated, &autoApproveString)
+	addDiffFlags(cmd.Flags(), &opts.DiffBaseOpts)
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
@@ -120,6 +148,9 @@ func pruneCmd() *cli.Command {
 		if opts.AutoApprove, err = validateAutoApprove(autoApproveDeprecated, autoApproveString); err != nil {
 			return err
 		}
+		if err := setForceColor(&opts.DiffBaseOpts); err != nil {
+			return err
+		}
 
 		opts.JsonnetOpts = getJsonnetOpts()
 
@@ -134,6 +165,9 @@ func deleteCmd() *cli.Command {
 		Use:   "delete <path>",
 		Short: "delete the environment from cluster",
 		Args:  workflowArgs,
+		Predictors: complete.Flags{
+			"color": colorValues,
+		},
 	}
 
 	var opts tanka.DeleteOpts
@@ -143,6 +177,7 @@ func deleteCmd() *cli.Command {
 		autoApproveString     string
 	)
 	addApplyFlags(cmd.Flags(), &opts.ApplyBaseOpts, &autoApproveDeprecated, &autoApproveString)
+	addDiffFlags(cmd.Flags(), &opts.DiffBaseOpts)
 	vars := workflowFlags(cmd.Flags())
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
@@ -154,6 +189,9 @@ func deleteCmd() *cli.Command {
 		if opts.AutoApprove, err = validateAutoApprove(autoApproveDeprecated, autoApproveString); err != nil {
 			return err
 		}
+		if err := setForceColor(&opts.DiffBaseOpts); err != nil {
+			return err
+		}
 
 		filters, err := process.StrExps(vars.targets...)
 		if err != nil {
@@ -174,11 +212,13 @@ func diffCmd() *cli.Command {
 		Short: "differences between the configuration and the cluster",
 		Args:  workflowArgs,
 		Predictors: complete.Flags{
+			"color":         colorValues,
 			"diff-strategy": cli.PredictSet("native", "subset", "validate", "server"),
 		},
 	}
 
 	var opts tanka.DiffOpts
+	addDiffFlags(cmd.Flags(), &opts.DiffBaseOpts)
 	cmd.Flags().StringVar(&opts.Strategy, "diff-strategy", "", "force the diff-strategy to use. Automatically chosen if not set.")
 	cmd.Flags().BoolVarP(&opts.Summarize, "summarize", "s", false, "print summary of the differences, not the actual contents")
 	cmd.Flags().BoolVarP(&opts.WithPrune, "with-prune", "p", false, "include objects deleted from the configuration in the differences")
@@ -188,6 +228,9 @@ func diffCmd() *cli.Command {
 	getJsonnetOpts := jsonnetFlags(cmd.Flags())
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
+		if err := setForceColor(&opts.DiffBaseOpts); err != nil {
+			return err
+		}
 		filters, err := process.StrExps(vars.targets...)
 		if err != nil {
 			return err
diff --git a/pkg/kubernetes/client/resources.go b/pkg/kubernetes/client/resources.go
index 9b4d5bc8..f29d5011 100644
--- a/pkg/kubernetes/client/resources.go
+++ b/pkg/kubernetes/client/resources.go
@@ -36,6 +36,7 @@ type Resource struct {
 	Namespaced bool   `json:"NAMESPACED,string"`
 	Shortnames string `json:"SHORTNAMES"`
 	Verbs      string `json:"VERBS"`
+	Categories string `json:"CATEGORIES"`
 }
 
 func (r Resource) FQN() string {
@@ -134,8 +135,13 @@ func splitRow(s string, header []string) (elems []string) {
 		}
 
 		lim := len(h)
-		elems = append(elems, s[pos:pos+lim])
-		pos += lim
+		endPos := pos + lim
+		if endPos >= len(s) {
+			endPos = len(s)
+		}
+
+		elems = append(elems, s[pos:endPos])
+		pos = endPos
 	}
 	return elems
 }
diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go
index 953d1678..adbc8c33 100644
--- a/pkg/tanka/workflow.go
+++ b/pkg/tanka/workflow.go
@@ -29,6 +29,7 @@ const (
 
 type ApplyBaseOpts struct {
 	Opts
+	DiffBaseOpts
 
 	// AutoApprove skips the interactive approval
 	AutoApprove AutoApproveSetting
@@ -38,6 +39,11 @@ type ApplyBaseOpts struct {
 	Force bool
 }
 
+type DiffBaseOpts struct {
+	// Color controls color output
+	Color string
+}
+
 // ApplyOpts specify additional properties for the Apply action
 type ApplyOpts struct {
 	ApplyBaseOpts
@@ -152,6 +158,7 @@ func confirmPrompt(action, namespace string, info client.Info) error {
 
 // DiffOpts specify additional properties for the Diff action
 type DiffOpts struct {
+	DiffBaseOpts
 	Opts
 
 	// Strategy must be one of "native", "validate", "subset" or "server"


Test patch:
diff --git a/pkg/kubernetes/client/resources_test.go b/pkg/kubernetes/client/resources_test.go
index c384f14d..f08107c6 100644
--- a/pkg/kubernetes/client/resources_test.go
+++ b/pkg/kubernetes/client/resources_test.go
@@ -35,7 +35,24 @@ func TestUnmarshalTable(t *testing.T) {
 	}{
 		{
 			name: "normal",
-			tbl:  strings.TrimSpace(tblNormal),
+			tbl:  strings.TrimSpace(tblv126),
+			want: &Resources{
+				{APIVersion: "v1", Kind: "Binding", Name: "bindings", Shortnames: "", Namespaced: true, Verbs: "create"},
+				{APIVersion: "v1", Kind: "ComponentStatus", Name: "componentstatuses", Shortnames: "cs", Namespaced: false, Verbs: "get,list"},
+				{APIVersion: "v1", Kind: "ConfigMap", Name: "configmaps", Shortnames: "cm", Namespaced: true, Verbs: "create,delete,deletecollection,get,list,patch,update,watch"},
+				{APIVersion: "v1", Kind: "Pod", Name: "pods", Shortnames: "po", Namespaced: true, Verbs: "create,delete,deletecollection,get,list,patch,update,watch", Categories: "all"},
+			},
+			wantFQNs: []string{
+				"bindings",
+				"componentstatuses",
+				"configmaps",
+				"pods",
+			},
+			dest: &Resources{},
+		},
+		{
+			name: "normal-v1.18-to-v.1.25",
+			tbl:  strings.TrimSpace(tblv121),
 			want: &Resources{
 				{APIVersion: "v1", Kind: "Namespace", Name: "namespaces", Shortnames: "ns", Namespaced: false},
 				{APIVersion: "apps/v1", Kind: "DaemonSet", Name: "daemonsets", Shortnames: "ds", Namespaced: true},
@@ -52,7 +69,7 @@ func TestUnmarshalTable(t *testing.T) {
 		},
 		{
 			name: "normal-v1.18-and-older",
-			tbl:  strings.TrimSpace(tblNormal1Dot18AndOlder),
+			tbl:  strings.TrimSpace(tblv118),
 			want: &Resources{
 				{APIGroup: "", Kind: "Namespace", Name: "namespaces", Shortnames: "ns", Namespaced: false},
 				{APIGroup: "apps", Kind: "DaemonSet", Name: "daemonsets", Shortnames: "ds", Namespaced: true},
@@ -97,9 +114,19 @@ func TestUnmarshalTable(t *testing.T) {
 	}
 }
 
+// This is a snippet that was pulled from output generated with kubectl v1.26.0
+// $ kubectl api-resources --cached --output=wide
+var tblv126 = `
+NAME                              SHORTNAMES   APIVERSION                        NAMESPACED   KIND                             VERBS                                                        CATEGORIES
+bindings                                       v1                                true         Binding                          create
+componentstatuses                 cs           v1                                false        ComponentStatus                  get,list
+configmaps                        cm           v1                                true         ConfigMap                        create,delete,deletecollection,get,list,patch,update,watch
+pods                              po           v1                                true         Pod                              create,delete,deletecollection,get,list,patch,update,watch   all
+`
+
 // this output was generated with kubectl v1.21.1
 // $ kubectl api-resources | grep -e "Deployment\|DaemonSet\|Namespace\|networking.k8s.io.*Ingress$\|KIND"
-var tblNormal = `
+var tblv121 = `
 NAME                                SHORTNAMES                             APIVERSION                             NAMESPACED   KIND
 namespaces                          ns                                     v1                                     false        Namespace
 daemonsets                          ds                                     apps/v1                                true         DaemonSet
@@ -109,7 +136,7 @@ ingresses                           ing                                    netwo
 
 // this output was generated with kubectl v1.18.10
 // $ kubectl api-resources | grep -e "Deployment\|DaemonSet\|Namespace\|networking.k8s.io.*Ingress$\|KIND"
-var tblNormal1Dot18AndOlder = `
+var tblv118 = `
 NAME                                SHORTNAMES                             APIGROUP                       NAMESPACED   KIND
 namespaces                          ns                                                                    false        Namespace
 daemonsets                          ds                                     apps                           true         DaemonSet