Repository: zegl/kube-score (go)

Issue:
Feature Request JSON Output
`kube-score` is pretty cool, is it possible to get JSON output? With JSON output it could be easily integrated with other things such as vscode etc. 

Pull request description:
JSON output
This fixes #132

```
RELNOTE: Added support for JSON output. Results in the "ci" and "human" modes are now rendered in alphabetical order. Results in "ci" mode without any comments are now always rendered.
```

Patch:
diff --git a/cmd/kube-score/main.go b/cmd/kube-score/main.go
index 3d25e06..8b19e52 100644
--- a/cmd/kube-score/main.go
+++ b/cmd/kube-score/main.go
@@ -1,13 +1,16 @@
 package main
 
 import (
+	"bytes"
 	"encoding/csv"
+	"encoding/json"
 	"fmt"
-	"io"
-	"os"
-
 	"github.com/fatih/color"
 	flag "github.com/spf13/pflag"
+	"io"
+	"io/ioutil"
+	"os"
+	"sort"
 
 	"github.com/zegl/kube-score/config"
 	"github.com/zegl/kube-score/parser"
@@ -79,7 +82,7 @@ func scoreFiles() error {
 	warningThreshold := fs.Int("threshold-warning", 5, "The score threshold for treating a score as WARNING. Grades below this threshold are CRITICAL. Must be between 1 and 10 (inclusive).")
 	verboseOutput := fs.Bool("v", false, "Verbose output")
 	printHelp := fs.Bool("help", false, "Print help")
-	outputFormat := fs.String("output-format", "human", "Set to 'human' or 'ci'. If set to ci, kube-score will output the program in a format that is easier to parse by other programs.")
+	outputFormat := fs.String("output-format", "human", "Set to 'human', 'json' or 'ci'. If set to ci, kube-score will output the program in a format that is easier to parse by other programs.")
 	ignoreTests := fs.StringSlice("ignore-test", []string{}, "Disable a test, can be set multiple times")
 	setDefault(fs, "score", false)
 
@@ -99,9 +102,9 @@ func scoreFiles() error {
 		return fmt.Errorf("Error: --threshold-ok and --threshold-warning must be set to a value between 1 and 10 inclusive.")
 	}
 
-	if *outputFormat != "human" && *outputFormat != "ci" {
+	if *outputFormat != "human" && *outputFormat != "ci" && *outputFormat != "json" {
 		fs.Usage()
-		return fmt.Errorf("Error: --output-format must be set to: 'human' or 'ci'")
+		return fmt.Errorf("Error: --output-format must be set to: 'human', 'json' or 'ci'")
 	}
 
 	filesToRead := fs.Args()
@@ -153,21 +156,87 @@ Use "-" as filename to read from STDIN.`)
 		ignoredTests[testID] = struct{}{}
 	}
 
-	hasWarning := false
-	hasCritical := false
+	var exitCode int
+	if scoreCard.AnyBelowOrEqualToGrade(scorecard.GradeCritical) {
+		exitCode = 1
+	} else if *exitOneOnWarning && scoreCard.AnyBelowOrEqualToGrade(scorecard.Grade(*warningThreshold)) {
+		exitCode = 1
+	} else {
+		exitCode = 0
+	}
+
+	var r io.Reader
+
+	if *outputFormat == "json" {
+		// TODO: Don't print tests that should be ignored, this is best solved by not executing those tests.
+		d, _ := json.MarshalIndent(scoreCard, "", "    ")
+		w := bytes.NewBufferString("")
+		w.WriteString(string(d))
+		r = w
+	} else if *outputFormat == "human" {
+		r = outputHuman(scoreCard, *okThreshold, *warningThreshold, ignoredTests)
+	} else {
+		r = outputCi(scoreCard, *okThreshold, *warningThreshold, ignoredTests)
+	}
+
+	output, _ := ioutil.ReadAll(r)
+	fmt.Print(string(output))
+	os.Exit(exitCode)
+	return nil
+}
 
-	// Detect which output format we should use
-	humanOutput := *outputFormat == "human"
+func listChecks() {
+	fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
+	printHelp := fs.Bool("help", false, "Print help")
+	setDefault(fs, "list", false)
+	fs.Parse(os.Args[2:])
+
+	if *printHelp {
+		fs.Usage()
+		return
+	}
+
+	allChecks := score.RegisterAllChecks(parser.Empty(), config.Configuration{})
+
+	output := csv.NewWriter(os.Stdout)
+	for _, c := range allChecks.All() {
+		output.Write([]string{c.ID, c.TargetType, c.Comment})
+	}
+	output.Flush()
+}
+
+func statusString(grade scorecard.Grade, okThreshold, warningThreshold int) string {
+	if grade >= scorecard.Grade(okThreshold) {
+		// Higher than or equal to --threshold-ok
+		return "OK"
+	} else if grade >= scorecard.Grade(warningThreshold) {
+		// Higher than or equal to --threshold-warning
+		return "WARNING"
+	} else {
+		// All lower than both --threshold-ok and --threshold-warning are critical
+		return "CRITICAL"
+	}
+}
+
+func outputHuman(scoreCard *scorecard.Scorecard, okThreshold, warningThreshold int, ignoredTests map[string]struct{}) io.Reader {
+	// Print the items sorted by scorecard key
+	var keys []string
+	for k := range *scoreCard {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+
+	w := bytes.NewBufferString("")
+
+	for _, key := range keys {
+		scoredObject := (*scoreCard)[key]
 
-	for _, scoredObject := range *scoreCard {
 		// Headers for each object
-		if humanOutput {
-			color.New(color.FgMagenta).Printf("%s/%s %s", scoredObject.TypeMeta.APIVersion, scoredObject.TypeMeta.Kind, scoredObject.ObjectMeta.Name)
-			if scoredObject.ObjectMeta.Namespace != "" {
-				color.New(color.FgMagenta).Printf(" in %s\n", scoredObject.ObjectMeta.Namespace)
-			} else {
-				fmt.Println()
-			}
+		color.New(color.FgMagenta).Fprintf(w, "%s/%s %s", scoredObject.TypeMeta.APIVersion, scoredObject.TypeMeta.Kind, scoredObject.ObjectMeta.Name)
+		if scoredObject.ObjectMeta.Namespace != "" {
+			color.New(color.FgMagenta).Fprintf(w, " in %s\n", scoredObject.ObjectMeta.Namespace)
+		} else {
+			fmt.Fprintln(w)
 		}
 
 		for _, card := range scoredObject.Checks {
@@ -175,89 +244,92 @@ Use "-" as filename to read from STDIN.`)
 				continue
 			}
 
-			var col color.Attribute
-			var status string
-
-			if card.Grade >= scorecard.Grade(*okThreshold) {
-				// Higher than or equal to --threshold-ok
-				col = color.FgGreen
-				status = "OK"
-			} else if card.Grade >= scorecard.Grade(*warningThreshold) {
-				// Higher than or equal to --threshold-warning
-				col = color.FgYellow
-				status = "WARNING"
-				hasWarning = true
-			} else {
-				// All lower than both --threshold-ok and --threshold-warning are critical
-				col = color.FgRed
-				status = "CRITICAL"
-				hasCritical = true
-			}
+			r := outputHumanStep(card, okThreshold, warningThreshold)
+			io.Copy(w, r)
+		}
 
-			if humanOutput {
-				color.New(col).Printf("    [%s] %s\n", status, card.Check.Name)
+	}
 
-				for _, comment := range card.Comments {
-					fmt.Printf("        * ")
+	return w
+}
 
-					if len(comment.Path) > 0 {
-						fmt.Printf("%s -> ", comment.Path)
-					}
+func outputHumanStep(card scorecard.TestScore, okThreshold, warningThreshold int) io.Reader {
+	var col color.Attribute
 
-					fmt.Print(comment.Summary)
+	if card.Grade >= scorecard.Grade(okThreshold) {
+		// Higher than or equal to --threshold-ok
+		col = color.FgGreen
+	} else if card.Grade >= scorecard.Grade(warningThreshold) {
+		// Higher than or equal to --threshold-warning
+		col = color.FgYellow
+	} else {
+		// All lower than both --threshold-ok and --threshold-warning are critical
+		col = color.FgRed
+	}
 
-					if len(comment.Description) > 0 {
-						fmt.Printf("\n             %s", comment.Description)
-					}
+	w := bytes.NewBufferString("")
 
-					fmt.Println()
-				}
-			} else {
-				// "Machine" / CI friendly output
-				for _, comment := range card.Comments {
-					message := comment.Summary
-					if comment.Path != "" {
-						message = "(" + comment.Path + ") " + comment.Summary
-					}
-
-					fmt.Printf("[%s] %s: %s\n",
-						status,
-						scoredObject.HumanFriendlyRef(),
-						message,
-					)
-				}
+	color.New(col).Fprintf(w, "    [%s] %s\n", statusString(card.Grade, okThreshold, warningThreshold), card.Check.Name)
 
-			}
+	for _, comment := range card.Comments {
+		fmt.Fprintf(w, "        * ")
+
+		if len(comment.Path) > 0 {
+			fmt.Fprintf(w, "%s -> ", comment.Path)
 		}
-	}
 
-	if hasCritical {
-		os.Exit(1)
-	} else if hasWarning && *exitOneOnWarning {
-		os.Exit(1)
-	} else {
-		os.Exit(0)
+		fmt.Fprint(w, comment.Summary)
+
+		if len(comment.Description) > 0 {
+			fmt.Fprintf(w, "\n             %s", comment.Description)
+		}
+
+		fmt.Fprintln(w)
 	}
 
-	return nil
+	return w
 }
 
-func listChecks() {
-	fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
-	printHelp := fs.Bool("help", false, "Print help")
-	setDefault(fs, "list", false)
-	fs.Parse(os.Args[2:])
+// "Machine" / CI friendly output
+func outputCi(scoreCard *scorecard.Scorecard, okThreshold, warningThreshold int, ignoredTests map[string]struct{}) io.Reader {
+	w := bytes.NewBufferString("")
 
-	if *printHelp {
-		fs.Usage()
-		return
+	// Print the items sorted by scorecard key
+	var keys []string
+	for k := range *scoreCard {
+		keys = append(keys, k)
 	}
+	sort.Strings(keys)
 
-	allChecks := score.RegisterAllChecks(parser.Empty(), config.Configuration{})
+	for _, key := range keys {
+		scoredObject := (*scoreCard)[key]
 
-	output := csv.NewWriter(os.Stdout)
-	for _, c := range allChecks.All() {
-		output.Write([]string{c.ID, c.TargetType, c.Comment})
+		for _, card := range scoredObject.Checks {
+			if _, ok := ignoredTests[card.Check.ID]; ok {
+				continue
+			}
+
+			if len(card.Comments) == 0 {
+				fmt.Fprintf(w, "[%s] %s\n",
+					statusString(card.Grade, okThreshold, warningThreshold),
+					scoredObject.HumanFriendlyRef(),
+				)
+			}
+
+			for _, comment := range card.Comments {
+				message := comment.Summary
+				if comment.Path != "" {
+					message = "(" + comment.Path + ") " + comment.Summary
+				}
+
+				fmt.Fprintf(w, "[%s] %s: %s\n",
+					statusString(card.Grade, okThreshold, warningThreshold),
+					scoredObject.HumanFriendlyRef(),
+					message,
+				)
+			}
+		}
 	}
-	output.Flush()
+
+	return w
 }
diff --git a/scorecard/scorecard.go b/scorecard/scorecard.go
index 5b3181d..4cf1cdd 100644
--- a/scorecard/scorecard.go
+++ b/scorecard/scorecard.go
@@ -35,6 +35,17 @@ func (s Scorecard) NewObject(typeMeta metav1.TypeMeta, objectMeta metav1.ObjectM
 	return o
 }
 
+func (s Scorecard) AnyBelowOrEqualToGrade(threshold Grade) bool {
+	for _, o := range s {
+		for _, s := range o.Checks {
+			if s.Grade <= threshold {
+				return true
+			}
+		}
+	}
+	return false
+}
+
 type ScoredObject struct {
 	TypeMeta   metav1.TypeMeta
 	ObjectMeta metav1.ObjectMeta


Test patch:
diff --git a/cmd/kube-score/output_test.go b/cmd/kube-score/output_test.go
new file mode 100644
index 0000000..bb6c1ac
--- /dev/null
+++ b/cmd/kube-score/output_test.go
@@ -0,0 +1,192 @@
+package main
+
+import (
+	"github.com/stretchr/testify/assert"
+	"io/ioutil"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"testing"
+
+	"github.com/zegl/kube-score/domain"
+	"github.com/zegl/kube-score/scorecard"
+)
+
+func TestCiOutput(t *testing.T) {
+	card := &scorecard.Scorecard{
+		"a": &scorecard.ScoredObject{
+			TypeMeta: v1.TypeMeta{
+				Kind:       "Testing",
+				APIVersion: "v1",
+			},
+			ObjectMeta: v1.ObjectMeta{
+				Name:      "foo",
+				Namespace: "foofoo",
+			},
+			Checks: []scorecard.TestScore{
+				{
+					Check: domain.Check{
+						Name: "TestingA",
+					},
+					Grade: scorecard.Grade(9),
+					Comments: []scorecard.TestScoreComment{
+						{
+							Path:        "a",
+							Summary:     "summary",
+							Description: "description",
+						},
+						{
+							// No path
+							Summary:     "summary",
+							Description: "description",
+						},
+					},
+				},
+			},
+		},
+
+		// No namespace
+		"b": &scorecard.ScoredObject{
+			TypeMeta: v1.TypeMeta{
+				Kind:       "Testing",
+				APIVersion: "v1",
+			},
+			ObjectMeta: v1.ObjectMeta{
+				Name: "bar-no-namespace",
+			},
+			Checks: []scorecard.TestScore{
+				{
+					Check: domain.Check{
+						Name: "TestingA",
+					},
+					Grade: scorecard.Grade(9),
+				},
+			},
+		},
+	}
+
+	ignoredTests := make(map[string]struct{})
+
+	// Defaults
+	r := outputCi(card, 10, 5, ignoredTests)
+	all, err := ioutil.ReadAll(r)
+	assert.Nil(t, err)
+	assert.Equal(t, `[WARNING] foo/foofoo v1/Testing: (a) summary
+[WARNING] foo/foofoo v1/Testing: summary
+[WARNING] bar-no-namespace v1/Testing
+`, string(all))
+
+	// OK at 9 or higher
+	r = outputCi(card, 9, 5, ignoredTests)
+	all, err = ioutil.ReadAll(r)
+	assert.Nil(t, err)
+	assert.Equal(t, `[OK] foo/foofoo v1/Testing: (a) summary
+[OK] foo/foofoo v1/Testing: summary
+[OK] bar-no-namespace v1/Testing
+`, string(all))
+
+	// OK at 8 or higher
+	r = outputCi(card, 8, 5, ignoredTests)
+	all, err = ioutil.ReadAll(r)
+	assert.Nil(t, err)
+	assert.Equal(t, `[OK] foo/foofoo v1/Testing: (a) summary
+[OK] foo/foofoo v1/Testing: summary
+[OK] bar-no-namespace v1/Testing
+`, string(all))
+}
+
+func TestHumanOutput(t *testing.T) {
+	card := &scorecard.Scorecard{
+		"a": &scorecard.ScoredObject{
+			TypeMeta: v1.TypeMeta{
+				Kind:       "Testing",
+				APIVersion: "v1",
+			},
+			ObjectMeta: v1.ObjectMeta{
+				Name:      "foo",
+				Namespace: "foofoo",
+			},
+			Checks: []scorecard.TestScore{
+				{
+					Check: domain.Check{
+						Name: "TestingA",
+					},
+					Grade: scorecard.Grade(9),
+					Comments: []scorecard.TestScoreComment{
+						{
+							Path:        "a",
+							Summary:     "summary",
+							Description: "description",
+						},
+						{
+							// No path
+							Summary:     "summary",
+							Description: "description",
+						},
+					},
+				},
+			},
+		},
+
+		// No namespace
+		"b": &scorecard.ScoredObject{
+			TypeMeta: v1.TypeMeta{
+				Kind:       "Testing",
+				APIVersion: "v1",
+			},
+			ObjectMeta: v1.ObjectMeta{
+				Name: "bar-no-namespace",
+			},
+			Checks: []scorecard.TestScore{
+				{
+					Check: domain.Check{
+						Name: "TestingA",
+					},
+					Grade: scorecard.Grade(9),
+				},
+			},
+		},
+	}
+
+	ignoredTests := make(map[string]struct{})
+
+	// Defaults
+	r := outputHuman(card, 10, 5, ignoredTests)
+	all, err := ioutil.ReadAll(r)
+	assert.Nil(t, err)
+	assert.Equal(t, `v1/Testing foo in foofoo
+    [WARNING] TestingA
+        * a -> summary
+             description
+        * summary
+             description
+v1/Testing bar-no-namespace
+    [WARNING] TestingA
+`, string(all))
+
+	// OK at 9 or higher
+	r = outputHuman(card, 9, 5, ignoredTests)
+	all, err = ioutil.ReadAll(r)
+	assert.Nil(t, err)
+	assert.Equal(t, `v1/Testing foo in foofoo
+    [OK] TestingA
+        * a -> summary
+             description
+        * summary
+             description
+v1/Testing bar-no-namespace
+    [OK] TestingA
+`, string(all))
+
+	// OK at 8 or higher
+	r = outputHuman(card, 8, 5, ignoredTests)
+	all, err = ioutil.ReadAll(r)
+	assert.Nil(t, err)
+	assert.Equal(t, `v1/Testing foo in foofoo
+    [OK] TestingA
+        * a -> summary
+             description
+        * summary
+             description
+v1/Testing bar-no-namespace
+    [OK] TestingA
+`, string(all))
+}