Repository: helm/helm (go)

Issue:
helm lint should allow specifying --kube-version flag
We're currently using `helm lint` to find deprecated API versions in our charts, failing CI on warnings. However, helm assumes that the target Kubernetes version is the one specified through the k8sVersionMajor/k8sVersionMinor vars. The only way to work around this is rebuilding helm with LDFLAGS, or using a Helm release with the proper defaults.

The command-line flag `--kube-version` currently exists with `helm template`, `helm lint` should replicate the behaviour:
https://github.com/helm/helm/pull/9040

Pull request:
lint: Add --kube-version flag to set capabilities and deprecation rules
Signed-off-by: Antoine Deschênes <antoine@antoinedeschenes.com>

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. Make sure to read the Contributing Guide before submitting your PR: https://github.com/helm/helm/blob/main/CONTRIBUTING.md
2. If this PR closes another issue, add 'closes #<issue number>' somewhere in the PR summary. GitHub will automatically close that issue when this PR gets merged. Alternatively, adding 'refs #<issue number>' will not close the issue, but help provide the reviewer more context.-->

**What this PR does / why we need it**:
closes #10664

Adds a `--kube-version` flag to the `helm lint` command, allowing to specify the Kubernetes version to use for the deprecation check warnings.

There's a similar flag in the `helm template` command. 

Specific example of where this is useful: 
- helm 3.8.0 releases are currently targetting Kubernetes 1.23. 
- We're currently running Kubernetes 1.22 clusters. 
- `helm lint --strict` is currently erroring out on HorizontalPodAutoscaler `autoscaling/v2beta2` having to be replaced by `autoscaling/v2`. 
- `autoscaling/v2` doesn't exist yet on Kubernetes 1.22. 


**If applicable**:
- [ ] this PR contains documentation
- [x] this PR contains unit tests
- [x] this PR has been tested for backwards compatibility


Patch:
diff --git a/cmd/helm/lint.go b/cmd/helm/lint.go
index 73a37b6fe..46f3c0efd 100644
--- a/cmd/helm/lint.go
+++ b/cmd/helm/lint.go
@@ -27,6 +27,7 @@ import (
 	"github.com/spf13/cobra"
 
 	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/cli/values"
 	"helm.sh/helm/v3/pkg/getter"
 	"helm.sh/helm/v3/pkg/lint/support"
@@ -44,6 +45,7 @@ or recommendation, it will emit [WARNING] messages.
 func newLintCmd(out io.Writer) *cobra.Command {
 	client := action.NewLint()
 	valueOpts := &values.Options{}
+	var kubeVersion string
 
 	cmd := &cobra.Command{
 		Use:   "lint PATH",
@@ -54,6 +56,15 @@ func newLintCmd(out io.Writer) *cobra.Command {
 			if len(args) > 0 {
 				paths = args
 			}
+
+			if kubeVersion != "" {
+				parsedKubeVersion, err := chartutil.ParseKubeVersion(kubeVersion)
+				if err != nil {
+					return fmt.Errorf("invalid kube version '%s': %s", kubeVersion, err)
+				}
+				client.KubeVersion = parsedKubeVersion
+			}
+
 			if client.WithSubcharts {
 				for _, p := range paths {
 					filepath.Walk(filepath.Join(p, "charts"), func(path string, info os.FileInfo, err error) error {
@@ -137,6 +148,7 @@ func newLintCmd(out io.Writer) *cobra.Command {
 	f.BoolVar(&client.Strict, "strict", false, "fail on lint warnings")
 	f.BoolVar(&client.WithSubcharts, "with-subcharts", false, "lint dependent charts")
 	f.BoolVar(&client.Quiet, "quiet", false, "print only warnings and errors")
+	f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for capabilities and deprecation checks")
 	addValueOptionsFlags(f, valueOpts)
 
 	return cmd
diff --git a/cmd/helm/testdata/testcharts/chart-with-deprecated-api/values.yaml b/cmd/helm/testdata/testcharts/chart-with-deprecated-api/values.yaml
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/action/lint.go b/pkg/action/lint.go
index e71cfe733..ca497f2b8 100644
--- a/pkg/action/lint.go
+++ b/pkg/action/lint.go
@@ -36,6 +36,7 @@ type Lint struct {
 	Namespace     string
 	WithSubcharts bool
 	Quiet         bool
+	KubeVersion   *chartutil.KubeVersion
 }
 
 // LintResult is the result of Lint
@@ -58,7 +59,7 @@ func (l *Lint) Run(paths []string, vals map[string]interface{}) *LintResult {
 	}
 	result := &LintResult{}
 	for _, path := range paths {
-		linter, err := lintChart(path, vals, l.Namespace, l.Strict)
+		linter, err := lintChart(path, vals, l.Namespace, l.KubeVersion)
 		if err != nil {
 			result.Errors = append(result.Errors, err)
 			continue
@@ -85,7 +86,7 @@ func HasWarningsOrErrors(result *LintResult) bool {
 	return len(result.Errors) > 0
 }
 
-func lintChart(path string, vals map[string]interface{}, namespace string, strict bool) (support.Linter, error) {
+func lintChart(path string, vals map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion) (support.Linter, error) {
 	var chartPath string
 	linter := support.Linter{}
 
@@ -124,5 +125,5 @@ func lintChart(path string, vals map[string]interface{}, namespace string, stric
 		return linter, errors.Wrap(err, "unable to check Chart.yaml file in chart")
 	}
 
-	return lint.All(chartPath, vals, namespace, strict), nil
+	return lint.AllWithKubeVersion(chartPath, vals, namespace, kubeVersion), nil
 }
diff --git a/pkg/lint/lint.go b/pkg/lint/lint.go
index 67e76bd3d..c0e79f55b 100644
--- a/pkg/lint/lint.go
+++ b/pkg/lint/lint.go
@@ -19,19 +19,25 @@ package lint // import "helm.sh/helm/v3/pkg/lint"
 import (
 	"path/filepath"
 
+	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/lint/rules"
 	"helm.sh/helm/v3/pkg/lint/support"
 )
 
 // All runs all of the available linters on the given base directory.
-func All(basedir string, values map[string]interface{}, namespace string, strict bool) support.Linter {
+func All(basedir string, values map[string]interface{}, namespace string, _ bool) support.Linter {
+	return AllWithKubeVersion(basedir, values, namespace, nil)
+}
+
+// AllWithKubeVersion runs all the available linters on the given base directory, allowing to specify the kubernetes version.
+func AllWithKubeVersion(basedir string, values map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion) support.Linter {
 	// Using abs path to get directory context
 	chartDir, _ := filepath.Abs(basedir)
 
 	linter := support.Linter{ChartDir: chartDir}
 	rules.Chartfile(&linter)
 	rules.ValuesWithOverrides(&linter, values)
-	rules.Templates(&linter, values, namespace, strict)
+	rules.TemplatesWithKubeVersion(&linter, values, namespace, kubeVersion)
 	rules.Dependencies(&linter)
 	return linter
 }
diff --git a/pkg/lint/rules/deprecations.go b/pkg/lint/rules/deprecations.go
index ce19b91d5..90e7748a4 100644
--- a/pkg/lint/rules/deprecations.go
+++ b/pkg/lint/rules/deprecations.go
@@ -24,6 +24,8 @@ import (
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apiserver/pkg/endpoints/deprecation"
 	kscheme "k8s.io/client-go/kubernetes/scheme"
+
+	"helm.sh/helm/v3/pkg/chartutil"
 )
 
 var (
@@ -45,7 +47,7 @@ func (e deprecatedAPIError) Error() string {
 	return msg
 }
 
-func validateNoDeprecations(resource *K8sYamlStruct) error {
+func validateNoDeprecations(resource *K8sYamlStruct, kubeVersion *chartutil.KubeVersion) error {
 	// if `resource` does not have an APIVersion or Kind, we cannot test it for deprecation
 	if resource.APIVersion == "" {
 		return nil
@@ -54,6 +56,14 @@ func validateNoDeprecations(resource *K8sYamlStruct) error {
 		return nil
 	}
 
+	majorVersion := k8sVersionMajor
+	minorVersion := k8sVersionMinor
+
+	if kubeVersion != nil {
+		majorVersion = kubeVersion.Major
+		minorVersion = kubeVersion.Minor
+	}
+
 	runtimeObject, err := resourceToRuntimeObject(resource)
 	if err != nil {
 		// do not error for non-kubernetes resources
@@ -62,11 +72,12 @@ func validateNoDeprecations(resource *K8sYamlStruct) error {
 		}
 		return err
 	}
-	maj, err := strconv.Atoi(k8sVersionMajor)
+
+	maj, err := strconv.Atoi(majorVersion)
 	if err != nil {
 		return err
 	}
-	min, err := strconv.Atoi(k8sVersionMinor)
+	min, err := strconv.Atoi(minorVersion)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/lint/rules/template.go b/pkg/lint/rules/template.go
index 1a061bd90..aa1dbb701 100644
--- a/pkg/lint/rules/template.go
+++ b/pkg/lint/rules/template.go
@@ -46,6 +46,11 @@ var (
 
 // Templates lints the templates in the Linter.
 func Templates(linter *support.Linter, values map[string]interface{}, namespace string, _ bool) {
+	TemplatesWithKubeVersion(linter, values, namespace, nil)
+}
+
+// TemplatesWithKubeVersion lints the templates in the Linter, allowing to specify the kubernetes version.
+func TemplatesWithKubeVersion(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion) {
 	fpath := "templates/"
 	templatesPath := filepath.Join(linter.ChartDir, fpath)
 
@@ -70,6 +75,11 @@ func Templates(linter *support.Linter, values map[string]interface{}, namespace
 		Namespace: namespace,
 	}
 
+	caps := chartutil.DefaultCapabilities.Copy()
+	if kubeVersion != nil {
+		caps.KubeVersion = *kubeVersion
+	}
+
 	// lint ignores import-values
 	// See https://github.com/helm/helm/issues/9658
 	if err := chartutil.ProcessDependenciesWithMerge(chart, values); err != nil {
@@ -80,7 +90,8 @@ func Templates(linter *support.Linter, values map[string]interface{}, namespace
 	if err != nil {
 		return
 	}
-	valuesToRender, err := chartutil.ToRenderValues(chart, cvals, options, nil)
+
+	valuesToRender, err := chartutil.ToRenderValues(chart, cvals, options, caps)
 	if err != nil {
 		linter.RunLinterRule(support.ErrorSev, fpath, err)
 		return
@@ -150,7 +161,7 @@ func Templates(linter *support.Linter, values map[string]interface{}, namespace
 					// NOTE: set to warnings to allow users to support out-of-date kubernetes
 					// Refs https://github.com/helm/helm/issues/8596
 					linter.RunLinterRule(support.WarningSev, fpath, validateMetadataName(yamlStruct))
-					linter.RunLinterRule(support.WarningSev, fpath, validateNoDeprecations(yamlStruct))
+					linter.RunLinterRule(support.WarningSev, fpath, validateNoDeprecations(yamlStruct, kubeVersion))
 
 					linter.RunLinterRule(support.ErrorSev, fpath, validateMatchSelector(yamlStruct, renderedContent))
 					linter.RunLinterRule(support.ErrorSev, fpath, validateListAnnotations(yamlStruct, renderedContent))


Test patch:
diff --git a/cmd/helm/lint_test.go b/cmd/helm/lint_test.go
index 314b54c35..166b69ba0 100644
--- a/cmd/helm/lint_test.go
+++ b/cmd/helm/lint_test.go
@@ -63,6 +63,34 @@ func TestLintCmdWithQuietFlag(t *testing.T) {
 
 }
 
+func TestLintCmdWithKubeVersionFlag(t *testing.T) {
+	testChart := "testdata/testcharts/chart-with-deprecated-api"
+	tests := []cmdTestCase{{
+		name:      "lint chart with deprecated api version using kube version flag",
+		cmd:       fmt.Sprintf("lint --kube-version 1.22.0 %s", testChart),
+		golden:    "output/lint-chart-with-deprecated-api.txt",
+		wantError: false,
+	}, {
+		name:      "lint chart with deprecated api version using kube version and strict flag",
+		cmd:       fmt.Sprintf("lint --kube-version 1.22.0 --strict %s", testChart),
+		golden:    "output/lint-chart-with-deprecated-api-strict.txt",
+		wantError: true,
+	}, {
+		// the test builds will use the default k8sVersionMinor const in deprecations.go and capabilities.go
+		// which is "20"
+		name:      "lint chart with deprecated api version without kube version",
+		cmd:       fmt.Sprintf("lint %s", testChart),
+		golden:    "output/lint-chart-with-deprecated-api-old-k8s.txt",
+		wantError: false,
+	}, {
+		name:      "lint chart with deprecated api version with older kube version",
+		cmd:       fmt.Sprintf("lint --kube-version 1.21.0 --strict %s", testChart),
+		golden:    "output/lint-chart-with-deprecated-api-old-k8s.txt",
+		wantError: false,
+	}}
+	runTestCmd(t, tests)
+}
+
 func TestLintFileCompletion(t *testing.T) {
 	checkFileCompletion(t, "lint", true)
 	checkFileCompletion(t, "lint mypath", true) // Multiple paths can be given
diff --git a/cmd/helm/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt b/cmd/helm/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt
new file mode 100644
index 000000000..bd0d70000
--- /dev/null
+++ b/cmd/helm/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt
@@ -0,0 +1,4 @@
+==> Linting testdata/testcharts/chart-with-deprecated-api
+[INFO] Chart.yaml: icon is recommended
+
+1 chart(s) linted, 0 chart(s) failed
diff --git a/cmd/helm/testdata/output/lint-chart-with-deprecated-api-strict.txt b/cmd/helm/testdata/output/lint-chart-with-deprecated-api-strict.txt
new file mode 100644
index 000000000..a1ec4394e
--- /dev/null
+++ b/cmd/helm/testdata/output/lint-chart-with-deprecated-api-strict.txt
@@ -0,0 +1,5 @@
+==> Linting testdata/testcharts/chart-with-deprecated-api
+[INFO] Chart.yaml: icon is recommended
+[WARNING] templates/horizontalpodautoscaler.yaml: autoscaling/v2beta1 HorizontalPodAutoscaler is deprecated in v1.22+, unavailable in v1.25+; use autoscaling/v2 HorizontalPodAutoscaler
+
+Error: 1 chart(s) linted, 1 chart(s) failed
diff --git a/cmd/helm/testdata/output/lint-chart-with-deprecated-api.txt b/cmd/helm/testdata/output/lint-chart-with-deprecated-api.txt
new file mode 100644
index 000000000..dac54620c
--- /dev/null
+++ b/cmd/helm/testdata/output/lint-chart-with-deprecated-api.txt
@@ -0,0 +1,5 @@
+==> Linting testdata/testcharts/chart-with-deprecated-api
+[INFO] Chart.yaml: icon is recommended
+[WARNING] templates/horizontalpodautoscaler.yaml: autoscaling/v2beta1 HorizontalPodAutoscaler is deprecated in v1.22+, unavailable in v1.25+; use autoscaling/v2 HorizontalPodAutoscaler
+
+1 chart(s) linted, 0 chart(s) failed
diff --git a/cmd/helm/testdata/testcharts/chart-with-deprecated-api/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-deprecated-api/Chart.yaml
new file mode 100644
index 000000000..3a6e99952
--- /dev/null
+++ b/cmd/helm/testdata/testcharts/chart-with-deprecated-api/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+appVersion: "1.0.0"
+description: A Helm chart for Kubernetes
+name: chart-with-deprecated-api
+type: application
+version: 1.0.0
diff --git a/cmd/helm/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml b/cmd/helm/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml
new file mode 100644
index 000000000..b77a4beeb
--- /dev/null
+++ b/cmd/helm/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml
@@ -0,0 +1,9 @@
+apiVersion: autoscaling/v2beta1
+kind: HorizontalPodAutoscaler
+metadata:
+  name: deprecated
+spec:
+  scaleTargetRef:
+    kind: Pod
+    name: pod
+  maxReplicas: 3
\ No newline at end of file
diff --git a/pkg/action/lint_test.go b/pkg/action/lint_test.go
index ff69407ca..80bf4ce7e 100644
--- a/pkg/action/lint_test.go
+++ b/pkg/action/lint_test.go
@@ -23,7 +23,6 @@ import (
 var (
 	values                  = make(map[string]interface{})
 	namespace               = "testNamespace"
-	strict                  = false
 	chart1MultipleChartLint = "testdata/charts/multiplecharts-lint-chart-1"
 	chart2MultipleChartLint = "testdata/charts/multiplecharts-lint-chart-2"
 	corruptedTgzChart       = "testdata/charts/corrupted-compressed-chart.tgz"
@@ -78,7 +77,7 @@ func TestLintChart(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			_, err := lintChart(tt.chartPath, map[string]interface{}{}, namespace, strict)
+			_, err := lintChart(tt.chartPath, map[string]interface{}{}, namespace, nil)
 			switch {
 			case err != nil && !tt.err:
 				t.Errorf("%s", err)
diff --git a/pkg/lint/rules/deprecations_test.go b/pkg/lint/rules/deprecations_test.go
index 96e072d14..cf2409007 100644
--- a/pkg/lint/rules/deprecations_test.go
+++ b/pkg/lint/rules/deprecations_test.go
@@ -23,7 +23,7 @@ func TestValidateNoDeprecations(t *testing.T) {
 		APIVersion: "extensions/v1beta1",
 		Kind:       "Deployment",
 	}
-	err := validateNoDeprecations(deprecated)
+	err := validateNoDeprecations(deprecated, nil)
 	if err == nil {
 		t.Fatal("Expected deprecated extension to be flagged")
 	}
@@ -35,7 +35,7 @@ func TestValidateNoDeprecations(t *testing.T) {
 	if err := validateNoDeprecations(&K8sYamlStruct{
 		APIVersion: "v1",
 		Kind:       "Pod",
-	}); err != nil {
+	}, nil); err != nil {
 		t.Errorf("Expected a v1 Pod to not be deprecated")
 	}
 }