Repository: zegl/kube-score (go)

Issue:
Let a specific version of Kubernetes be specified when kube-scoring a file
e.g. a warning like the below is emitted when kube-scoring a Deployment file
```
    [WARNING] Stable version
        * The apiVersion and kind extensions/v1beta1/Deployment is deprecated
             It's recommended to use apps/v1 instead
```
Deprecation information is Kubernetes version-specific I'm guessing, so this warning should depend on the version of Kubernetes being used.

Pull request:
User specified --kubernetes-version
```
RELNOTE: Added a new --kubernetes-version flag to score. Set --kubernetes-version to the version of Kubernetes in use to tailor the recommendations to that specific version. The "Stable version" score has been modified to only recommend upgrades to APIs that are GA in the user specified version of Kubernetes.
```

This fixes #63 (at last!) 🎉 


Patch:
diff --git a/README.md b/README.md
index 22dceef..f96c522 100644
--- a/README.md
+++ b/README.md
@@ -108,8 +108,9 @@ Flags for score:
       --ignore-container-cpu-limit          Disables the requirement of setting a container CPU limit
       --ignore-container-memory-limit       Disables the requirement of setting a container memory limit
       --ignore-test strings                 Disable a test, can be set multiple times
+      --kubernetes-version string           Setting the kubernetes-version will affect the checks ran against the manifests. Set this to the version of Kubernetes that you're using in production for the best results. (default "v1.18")
   -o, --output-format string                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. (default "human")
-      --output-version string               Changes the version of the --output-format. The 'json' format has version 'v1' (default) and 'v2'. The 'human' and 'ci' formats has only version 'v1' (default). If not explicitly set, the default version for that particular output format will be used.
+      --output-version string               Changes the version of the --output-format. The 'json' format has version 'v2' (default) and 'v1' (deprecated, will be removed in v1.7.0). The 'human' and 'ci' formats has only version 'v1' (default). If not explicitly set, the default version for that particular output format will be used.
   -v, --verbose count                       Enable verbose output, can be set multiple times for increased verbosity.
 ```
 
diff --git a/cmd/kube-score/main.go b/cmd/kube-score/main.go
index 6c293be..bf5e903 100644
--- a/cmd/kube-score/main.go
+++ b/cmd/kube-score/main.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"encoding/csv"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -102,6 +103,7 @@ func scoreFiles(binName string, args []string) error {
 	optionalTests := fs.StringSlice("enable-optional-test", []string{}, "Enable an optional test, can be set multiple times")
 	ignoreTests := fs.StringSlice("ignore-test", []string{}, "Disable a test, can be set multiple times")
 	disableIgnoreChecksAnnotation := fs.Bool("disable-ignore-checks-annotations", false, "Set to true to disable the effect of the 'kube-score/ignore' annotations")
+	kubernetesVersion := fs.String("kubernetes-version", "v1.18", "Setting the kubernetes-version will affect the checks ran against the manifests. Set this to the version of Kubernetes that you're using in production for the best results.")
 	setDefault(fs, binName, "score", false)
 
 	err := fs.Parse(args)
@@ -149,6 +151,11 @@ Use "-" as filename to read from STDIN.`, execName(binName))
 	ignoredTests := listToStructMap(ignoreTests)
 	enabledOptionalTests := listToStructMap(optionalTests)
 
+	kubeVer, err := config.ParseSemver(*kubernetesVersion)
+	if err != nil {
+		return errors.New("Invalid --kubernetes-version. Use on format \"vN.NN\"")
+	}
+
 	cnf := config.Configuration{
 		AllFiles:                              allFilePointers,
 		VerboseOutput:                         *verboseOutput,
@@ -157,6 +164,7 @@ Use "-" as filename to read from STDIN.`, execName(binName))
 		IgnoredTests:                          ignoredTests,
 		EnabledOptionalTests:                  enabledOptionalTests,
 		UseIgnoreChecksAnnotation:             !*disableIgnoreChecksAnnotation,
+		KubernetesVersion:                     kubeVer,
 	}
 
 	parsedFiles, err := parser.ParseFiles(cnf)
diff --git a/config/config.go b/config/config.go
index 54f86d6..84c8fdc 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,6 +1,11 @@
 package config
 
-import "io"
+import (
+	"errors"
+	"io"
+	"strconv"
+	"strings"
+)
 
 type Configuration struct {
 	AllFiles                              []io.Reader
@@ -10,4 +15,53 @@ type Configuration struct {
 	IgnoredTests                          map[string]struct{}
 	EnabledOptionalTests                  map[string]struct{}
 	UseIgnoreChecksAnnotation             bool
+	KubernetesVersion                     Semver
+}
+
+type Semver struct {
+	Major int
+	Minor int
+}
+
+var errInvalidSemver = errors.New("invalid semver")
+
+func ParseSemver(s string) (Semver, error) {
+	if len(s) == 0 {
+		return Semver{}, errInvalidSemver
+	}
+	start := 0
+	if s[0] == 'v' {
+		start = 1
+	}
+
+	// Separate by .
+	parts := strings.Split(s[start:], ".")
+	if len(parts) != 2 {
+		return Semver{}, errInvalidSemver
+	}
+
+	major, err := strconv.Atoi(parts[0])
+	if err != nil {
+		return Semver{}, errInvalidSemver
+	}
+
+	minor, err := strconv.Atoi(parts[1])
+	if err != nil {
+		return Semver{}, errInvalidSemver
+	}
+
+	return Semver{
+		Major: major,
+		Minor: minor,
+	}, nil
+}
+
+func (s Semver) LessThan(other Semver) bool {
+	if s.Major < other.Major {
+		return true
+	}
+	if s.Major == other.Major && s.Minor < other.Minor {
+		return true
+	}
+	return false
 }
diff --git a/score/score.go b/score/score.go
index af7dbd3..bc1ce61 100644
--- a/score/score.go
+++ b/score/score.go
@@ -33,7 +33,7 @@ func RegisterAllChecks(allObjects ks.AllTypes, cnf config.Configuration) *checks
 	probes.Register(allChecks, allObjects)
 	security.Register(allChecks)
 	service.Register(allChecks, allObjects, allObjects)
-	stable.Register(allChecks)
+	stable.Register(cnf.KubernetesVersion, allChecks)
 	apps.Register(allChecks, allObjects.HorizontalPodAutoscalers())
 	meta.Register(allChecks)
 	hpa.Register(allChecks, allObjects.Metas())
diff --git a/score/stable/stable_version.go b/score/stable/stable_version.go
index 61360cc..8842fda 100644
--- a/score/stable/stable_version.go
+++ b/score/stable/stable_version.go
@@ -3,44 +3,60 @@ package stable
 import (
 	"fmt"
 
+	"github.com/zegl/kube-score/config"
 	"github.com/zegl/kube-score/domain"
 	"github.com/zegl/kube-score/score/checks"
 	"github.com/zegl/kube-score/scorecard"
 )
 
-func Register(allChecks *checks.Checks) {
-	allChecks.RegisterMetaCheck("Stable version", `Checks if the object is using a deprecated apiVersion`, metaStableAvailable)
+func Register(kubernetesVersion config.Semver, allChecks *checks.Checks) {
+	allChecks.RegisterMetaCheck("Stable version", `Checks if the object is using a deprecated apiVersion`, metaStableAvailable(kubernetesVersion))
 }
 
 // ScoreMetaStableAvailable checks if the supplied TypeMeta is an unstable object type, that has a stable(r) replacement
-func metaStableAvailable(meta domain.BothMeta) (score scorecard.TestScore) {
-	withStable := map[string]map[string]string{
-		"extensions/v1beta1": {
-			"Deployment": "apps/v1",
-			"DaemonSet":  "apps/v1",
-		},
-		"apps/v1beta1": {
-			"Deployment":  "apps/v1",
-			"StatefulSet": "apps/v1",
-		},
-		"apps/v1beta2": {
-			"Deployment":  "apps/v1",
-			"StatefulSet": "apps/v1",
-			"DaemonSet":   "apps/v1",
-		},
-	}
+func metaStableAvailable(kubernetsVersion config.Semver) func(meta domain.BothMeta) (score scorecard.TestScore) {
+	return func(meta domain.BothMeta) (score scorecard.TestScore) {
+		type recommendedApi struct {
+			newAPI         string
+			availableSince config.Semver
+		}
 
-	if inVersion, ok := withStable[meta.TypeMeta.APIVersion]; ok {
-		if recommendedVersion, ok := inVersion[meta.TypeMeta.Kind]; ok {
-			score.Grade = scorecard.GradeWarning
-			score.AddComment("",
-				fmt.Sprintf("The apiVersion and kind %s/%s is deprecated", meta.TypeMeta.APIVersion, meta.TypeMeta.Kind),
-				fmt.Sprintf("It's recommended to use %s instead", recommendedVersion),
-			)
-			return
+		withStable := map[string]map[string]recommendedApi{
+			"extensions/v1beta1": {
+				"Deployment": recommendedApi{"apps/v1", config.Semver{1, 9}},
+				"DaemonSet":  recommendedApi{"apps/v1", config.Semver{1, 9}},
+			},
+			"apps/v1beta1": {
+				"Deployment":  recommendedApi{"apps/v1", config.Semver{1, 9}},
+				"StatefulSet": recommendedApi{"apps/v1", config.Semver{1, 9}},
+			},
+			"apps/v1beta2": {
+				"Deployment":  recommendedApi{"apps/v1", config.Semver{1, 9}},
+				"StatefulSet": recommendedApi{"apps/v1", config.Semver{1, 9}},
+				"DaemonSet":   recommendedApi{"apps/v1", config.Semver{1, 9}},
+			},
 		}
-	}
 
-	score.Grade = scorecard.GradeAllOK
-	return
+		score.Grade = scorecard.GradeAllOK
+
+		if inVersion, ok := withStable[meta.TypeMeta.APIVersion]; ok {
+			if recAPI, ok := inVersion[meta.TypeMeta.Kind]; ok {
+
+				// The recommended replacement is not available in the version of Kubernetes
+				// that the user is using
+				if kubernetsVersion.LessThan(recAPI.availableSince) {
+					return
+				}
+
+				score.Grade = scorecard.GradeWarning
+				score.AddComment("",
+					fmt.Sprintf("The apiVersion and kind %s/%s is deprecated", meta.TypeMeta.APIVersion, meta.TypeMeta.Kind),
+					fmt.Sprintf("It's recommended to use %s instead", recAPI.newAPI),
+				)
+				return
+			}
+		}
+
+		return
+	}
 }


Test patch:
diff --git a/config/semver_test.go b/config/semver_test.go
new file mode 100644
index 0000000..97c192a
--- /dev/null
+++ b/config/semver_test.go
@@ -0,0 +1,48 @@
+package config
+
+import (
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestSemver(t *testing.T) {
+	tc := []struct {
+		input       string
+		expected    Semver
+		expectedErr error
+	}{
+		{"v1.0", Semver{1, 0}, nil},
+		{"v1.999", Semver{1, 999}, nil},
+		{"1.0", Semver{1, 0}, nil},
+		{"1.999", Semver{1, 999}, nil},
+
+		{"foo", Semver{}, errInvalidSemver},
+		{"v1.2.3", Semver{}, errInvalidSemver},
+		{"v1.foo", Semver{}, errInvalidSemver},
+		{"x1.0", Semver{}, errInvalidSemver},
+		{"v0x00.123", Semver{}, errInvalidSemver},
+		{"v1b.5nn3", Semver{}, errInvalidSemver},
+	}
+
+	for d, tc := range tc {
+		s, e := ParseSemver(tc.input)
+		assert.Equal(t, tc.expected, s, "Case: %d", d)
+		assert.Equal(t, tc.expectedErr, e, "Case: %d", d)
+	}
+}
+
+func TestSemverLessThan(t *testing.T) {
+	tc := []struct {
+		a        Semver
+		b        Semver
+		expected bool
+	}{
+		{Semver{1, 0}, Semver{1, 0}, false},
+		{Semver{1, 0}, Semver{1, 2}, true},
+		{Semver{1, 6}, Semver{2, 0}, true},
+		{Semver{2, 6}, Semver{1, 18}, false},
+	}
+	for d, tc := range tc {
+		assert.Equal(t, tc.expected, tc.a.LessThan(tc.b), "Case: %d", d)
+	}
+}
diff --git a/score/score_test.go b/score/score_test.go
index 508a077..81d30f1 100644
--- a/score/score_test.go
+++ b/score/score_test.go
@@ -55,14 +55,16 @@ func testScore(config config.Configuration) (scorecard.Scorecard, error) {
 
 func testExpectedScore(t *testing.T, filename string, testcase string, expectedScore scorecard.Grade) []scorecard.TestScoreComment {
 	return testExpectedScoreWithConfig(t, config.Configuration{
-		AllFiles: []io.Reader{testFile(filename)},
+		AllFiles:          []io.Reader{testFile(filename)},
+		KubernetesVersion: config.Semver{1, 18},
 	}, testcase, expectedScore)
 }
 
 func testExpectedScoreReader(t *testing.T, content io.Reader, testcase string, expectedScore scorecard.Grade) []scorecard.TestScoreComment {
 	return testExpectedScoreWithConfig(
 		t, config.Configuration{
-			AllFiles: []io.Reader{content},
+			AllFiles:          []io.Reader{content},
+			KubernetesVersion: config.Semver{1, 18},
 		},
 		testcase,
 		expectedScore,
diff --git a/score/stable/stable_version_test.go b/score/stable/stable_version_test.go
new file mode 100644
index 0000000..9473099
--- /dev/null
+++ b/score/stable/stable_version_test.go
@@ -0,0 +1,25 @@
+package stable
+
+import (
+	"github.com/stretchr/testify/assert"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"testing"
+
+	"github.com/zegl/kube-score/config"
+	ks "github.com/zegl/kube-score/domain"
+	"github.com/zegl/kube-score/scorecard"
+)
+
+func TestStableVersionOldKubernetesVersion(t *testing.T) {
+	oldKubernetes := metaStableAvailable(config.Semver{1, 4})
+	scoreOld := oldKubernetes(ks.BothMeta{TypeMeta: v1.TypeMeta{Kind: "Deployment", APIVersion: "extensions/v1beta1"}})
+	assert.Equal(t, scorecard.GradeAllOK, scoreOld.Grade)
+	assert.Equal(t, []scorecard.TestScoreComment(nil), scoreOld.Comments)
+}
+
+func TestStableVersionNewKubernetesVersion(t *testing.T) {
+	newKubernetes := metaStableAvailable(config.Semver{1, 18})
+	scoreNew := newKubernetes(ks.BothMeta{TypeMeta: v1.TypeMeta{Kind: "Deployment", APIVersion: "extensions/v1beta1"}})
+	assert.Equal(t, scorecard.GradeWarning, scoreNew.Grade)
+	assert.Equal(t, []scorecard.TestScoreComment{{Path: "", Summary: "The apiVersion and kind extensions/v1beta1/Deployment is deprecated", Description: "It's recommended to use apps/v1 instead", DocumentationURL: ""}}, scoreNew.Comments)
+}
diff --git a/score/stable_version_test.go b/score/stable_version_test.go
index 7be91a7..3021f47 100644
--- a/score/stable_version_test.go
+++ b/score/stable_version_test.go
@@ -1,8 +1,10 @@
 package score
 
 import (
+	"io"
 	"testing"
 
+	"github.com/zegl/kube-score/config"
 	"github.com/zegl/kube-score/scorecard"
 )
 
@@ -11,6 +13,22 @@ func TestStatefulSetAppsv1beta1(t *testing.T) {
 	testExpectedScore(t, "statefulset-appsv1beta1.yaml", "Stable version", scorecard.GradeWarning)
 }
 
+func TestStatefulSetAppsv1beta1Kubernetes1dot4(t *testing.T) {
+	t.Parallel()
+	testExpectedScoreWithConfig(t, config.Configuration{
+		AllFiles:          []io.Reader{testFile("statefulset-appsv1beta1.yaml")},
+		KubernetesVersion: config.Semver{1, 4},
+	}, "Stable version", scorecard.GradeAllOK)
+}
+
+func TestStatefulSetAppsv1beta1Kubernetes1dot18(t *testing.T) {
+	t.Parallel()
+	testExpectedScoreWithConfig(t, config.Configuration{
+		AllFiles:          []io.Reader{testFile("statefulset-appsv1beta1.yaml")},
+		KubernetesVersion: config.Semver{1, 18},
+	}, "Stable version", scorecard.GradeWarning)
+}
+
 func TestStatefulSetAppsv1beta2(t *testing.T) {
 	t.Parallel()
 	testExpectedScore(t, "statefulset-appsv1beta2.yaml", "Stable version", scorecard.GradeWarning)