Repository: grafana/tanka
Pull request #1018 (2024-04-19 20:10:13)

Add version-check command for vendored helm charts
## What are we doing
This change is  a part of a larger effort to enable more automatic helm chart version management. First step is to get a command that can search the repositories within a `chartfile.yaml` for newer versions of the required charts. This output should be easy to parse for another script in order to kick off an automation to add the chart, vendor it, and make a PR.

## Testing
Adding 2 different flux2 requires returns only 1 chart update :
```bash
$ ../../../../../go/src/github.com/grafana/tanka/tk tool charts version-check | jq
[
  {
    "name": "fluxcd-community/flux2",
    "version": "2.12.4",
    "app_version": "2.2.3",
    "description": "A Helm chart for flux2"
  }
]
```
**Potential question here, should we be searching the existing requires for this version and exclude it if so? Right now if you have versions 2.12.4 and 2.12.3 of flux2 it would still return this even though you have the most up to date version.**

Private repositories, I modified the `chartfile.yaml` to be a lower version to test it can search private repos:
```bash
$ ../../../../../go/src/github.com/grafana/tanka/tk tool charts version-check --repository-config $HOME/Library/Preferences/helm/repositories.yaml | jq
[
  {
    "name": "simkev2/nginx-ingress",
    "version": "1.2.0",
    "app_version": "3.5.0",
    "description": "NGINX Ingress Controller"
  }
]
``` 

SimKev2: Running now with example chartfile
```yaml
# chartfile.yaml
directory: charts
repositories:
- name: fluxcd-community
  url: https://fluxcd-community.github.io/helm-charts
requires:
- chart: fluxcd-community/flux2
  directory: 2.11.0
  version: 2.11.0
- chart: simkev2/nginx-ingress
  directory: 1.1.0
  version: 1.1.0
version: 1
```

Note that my private repo here only has a version for `1.2.0` of `nginx-ingress`, and the current latest version of `flux2` is `2.12.4`.

Without repo config does not get nginx-ingress update :
```bash
$ ../../../../../go/src/github.com/grafana/tanka/tk tool charts version-check | jq
{
  "fluxcd-community/flux2@2.11.0": {
    "name": "fluxcd-community/flux2",
    "directory": "2.11.0",
    "current_version": "2.11.0",
    "using_latest_version": false,
    "latest_version": {
      "name": "fluxcd-community/flux2",
      "version": "2.12.4",
      "app_version": "2.2.3",
      "description": "A Helm chart for flux2"
    },
    "latest_matching_major_version": {
      "name": "fluxcd-community/flux2",
      "version": "2.12.4",
      "app_version": "2.2.3",
      "description": "A Helm chart for flux2"
    },
    "latest_matching_minor_version": {
      "name": "fluxcd-community/flux2",
      "version": "2.11.1",
      "app_version": "2.1.2",
      "description": "A Helm chart for flux2"
    }
  },
  "simkev2/nginx-ingress@1.1.0": {
    "name": "simkev2/nginx-ingress",
    "directory": "1.1.0",
    "current_version": "1.1.0",
    "using_latest_version": true,
    "latest_version": {
      "name": "simkev2/nginx-ingress",
      "version": "1.1.0",
      "description": "search did not return 1 version"
    },
    "latest_matching_major_version": {
      "name": "simkev2/nginx-ingress",
      "version": "1.1.0",
      "description": "search did not return 1 version"
    },
    "latest_matching_minor_version": {
      "name": "simkev2/nginx-ingress",
      "version": "1.1.0",
      "description": "search did not return 1 version"
    }
  }
}
```

With repo config does search private repository as well as the public one :
```bash
$ ../../../../../go/src/github.com/grafana/tanka/tk tool charts version-check --repository-config $HOME/Library/Preferences/helm/repositories.yaml | jq
{
  "fluxcd-community/flux2@2.11.0": {
    "name": "fluxcd-community/flux2",
    "directory": "2.11.0",
    "current_version": "2.11.0",
    "using_latest_version": false,
    "latest_version": {
      "name": "fluxcd-community/flux2",
      "version": "2.12.4",
      "app_version": "2.2.3",
      "description": "A Helm chart for flux2"
    },
    "latest_matching_major_version": {
      "name": "fluxcd-community/flux2",
      "version": "2.12.4",
      "app_version": "2.2.3",
      "description": "A Helm chart for flux2"
    },
    "latest_matching_minor_version": {
      "name": "fluxcd-community/flux2",
      "version": "2.11.1",
      "app_version": "2.1.2",
      "description": "A Helm chart for flux2"
    }
  },
  "simkev2/nginx-ingress@1.1.0": {
    "name": "simkev2/nginx-ingress",
    "directory": "1.1.0",
    "current_version": "1.1.0",
    "using_latest_version": false,
    "latest_version": {
      "name": "simkev2/nginx-ingress",
      "version": "1.2.0",
      "app_version": "3.5.0",
      "description": "NGINX Ingress Controller"
    },
    "latest_matching_major_version": {
      "name": "simkev2/nginx-ingress",
      "version": "1.2.0",
      "app_version": "3.5.0",
      "description": "NGINX Ingress Controller"
    },
    "latest_matching_minor_version": {
      "name": "simkev2/nginx-ingress",
      "version": "1.1.0",
      "description": "search did not return 1 version"
    }
  }
}
```

Patch:
diff --git a/cmd/tk/toolCharts.go b/cmd/tk/toolCharts.go
index 84e0fd5d..cf032f1b 100644
--- a/cmd/tk/toolCharts.go
+++ b/cmd/tk/toolCharts.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"encoding/json"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -10,6 +11,8 @@ import (
 	"gopkg.in/yaml.v2"
 )
 
+const repoConfigFlagUsage = "specify a local helm repository config file to use instead of the repositories in the chartfile.yaml. For use with private repositories"
+
 func chartsCmd() *cli.Command {
 	cmd := &cli.Command{
 		Use:   "charts",
@@ -24,6 +27,7 @@ func chartsCmd() *cli.Command {
 		chartsAddRepoCmd(),
 		chartsVendorCmd(),
 		chartsConfigCmd(),
+		chartsVersionCheckCmd(),
 	)
 
 	return cmd
@@ -35,7 +39,7 @@ func chartsVendorCmd() *cli.Command {
 		Short: "Download Charts to a local folder",
 	}
 	prune := cmd.Flags().Bool("prune", false, "also remove non-vendored files from the destination directory")
-	repoConfigPath := cmd.Flags().String("repository-config", "", "specify a local helm repository config file to use instead of the repositories in the chartfile.yaml. For use with private repositories")
+	repoConfigPath := cmd.Flags().String("repository-config", "", repoConfigFlagUsage)
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
 		c, err := loadChartfile()
@@ -54,7 +58,7 @@ func chartsAddCmd() *cli.Command {
 		Use:   "add [chart@version] [...]",
 		Short: "Adds Charts to the chartfile",
 	}
-	repoConfigPath := cmd.Flags().String("repository-config", "", "specify a local helm repository config file to use instead of the repositories in the chartfile.yaml. For use with private repositories")
+	repoConfigPath := cmd.Flags().String("repository-config", "", repoConfigFlagUsage)
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
 		c, err := loadChartfile()
@@ -143,6 +147,35 @@ func chartsInitCmd() *cli.Command {
 	return cmd
 }
 
+func chartsVersionCheckCmd() *cli.Command {
+	cmd := &cli.Command{
+		Use:   "version-check",
+		Short: "Check required charts for updated versions",
+	}
+	repoConfigPath := cmd.Flags().String("repository-config", "", repoConfigFlagUsage)
+	prettyPrint := cmd.Flags().Bool("pretty-print", false, "pretty print json output with indents")
+
+	cmd.Run = func(cmd *cli.Command, args []string) error {
+		c, err := loadChartfile()
+		if err != nil {
+			return err
+		}
+
+		data, err := c.VersionCheck(*repoConfigPath)
+		if err != nil {
+			return err
+		}
+
+		enc := json.NewEncoder(os.Stdout)
+		if *prettyPrint {
+			enc.SetIndent("", "  ")
+		}
+		return enc.Encode(data)
+	}
+
+	return cmd
+}
+
 func loadChartfile() (*helm.Charts, error) {
 	wd, err := os.Getwd()
 	if err != nil {
diff --git a/pkg/helm/charts.go b/pkg/helm/charts.go
index 73b692e2..e866bc06 100644
--- a/pkg/helm/charts.go
+++ b/pkg/helm/charts.go
@@ -118,12 +118,9 @@ func (c Charts) Vendor(prune bool, repoConfigPath string) error {
 		return err
 	}
 
-	if repoConfigPath != "" {
-		repoConfig, err := LoadHelmRepoConfig(repoConfigPath)
-		if err != nil {
-			return err
-		}
-		c.Manifest.Repositories = repoConfig.Repositories
+	repositories, err := c.getRepositories(repoConfigPath)
+	if err != nil {
+		return err
 	}
 
 	// Check that there are no output conflicts before vendoring
@@ -185,19 +182,19 @@ func (c Charts) Vendor(prune bool, repoConfigPath string) error {
 
 		if !repositoriesUpdated {
 			log.Info().Msg("Syncing Repositories ...")
-			if err := c.Helm.RepoUpdate(Opts{Repositories: c.Manifest.Repositories}); err != nil {
+			if err := c.Helm.RepoUpdate(Opts{Repositories: repositories}); err != nil {
 				return err
 			}
 			repositoriesUpdated = true
 		}
 		log.Info().Msg("Pulling Charts ...")
-		if repoName := parseReqRepo(r.Chart); !c.Manifest.Repositories.HasName(repoName) {
+		if repoName := parseReqRepo(r.Chart); !repositories.HasName(repoName) {
 			return fmt.Errorf("repository %q not found for chart %q", repoName, r.Chart)
 		}
 		err := c.Helm.Pull(r.Chart, r.Version, PullOpts{
 			Destination:      dir,
 			ExtractDirectory: r.Directory,
-			Opts:             Opts{Repositories: c.Manifest.Repositories},
+			Opts:             Opts{Repositories: repositories},
 		})
 		if err != nil {
 			return err
@@ -302,6 +299,50 @@ func (c *Charts) AddRepos(repos ...Repo) error {
 	return nil
 }
 
+// VersionCheck checks each of the charts in the requires section and returns information regarding
+// related to version upgrades. This includes if the current version is latest as well as the
+// latest matching versions of the major and minor version the chart is currently on.
+func (c *Charts) VersionCheck(repoConfigPath string) (map[string]RequiresVersionInfo, error) {
+	requiresVersionInfo := make(map[string]RequiresVersionInfo)
+	repositories, err := c.getRepositories(repoConfigPath)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, r := range c.Manifest.Requires {
+		searchVersions, err := c.Helm.SearchRepo(r.Chart, r.Version, Opts{Repositories: repositories})
+		if err != nil {
+			return nil, err
+		}
+		usingLatestVersion := r.Version == searchVersions[0].Version
+
+		requiresVersionInfo[fmt.Sprintf("%s@%s", r.Chart, r.Version)] = RequiresVersionInfo{
+			Name:                       r.Chart,
+			Directory:                  r.Directory,
+			CurrentVersion:             r.Version,
+			UsingLatestVersion:         usingLatestVersion,
+			LatestVersion:              searchVersions[0],
+			LatestMatchingMajorVersion: searchVersions[1],
+			LatestMatchingMinorVersion: searchVersions[2],
+		}
+	}
+
+	return requiresVersionInfo, nil
+}
+
+// getRepositories will dynamically return the repositores either loaded from the given
+// repoConfigPath file or from the existing manifest.
+func (c *Charts) getRepositories(repoConfigPath string) (Repos, error) {
+	if repoConfigPath != "" {
+		repoConfig, err := LoadHelmRepoConfig(repoConfigPath)
+		if err != nil {
+			return nil, err
+		}
+		return repoConfig.Repositories, nil
+	}
+	return c.Manifest.Repositories, nil
+}
+
 func InitChartfile(path string) (*Charts, error) {
 	c := Chartfile{
 		Version: Version,
diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go
index 407cd6be..79d9b2c4 100644
--- a/pkg/helm/helm.go
+++ b/pkg/helm/helm.go
@@ -26,6 +26,9 @@ type Helm interface {
 
 	// ChartExists checks if a chart exists in the provided calledFromPath
 	ChartExists(chart string, opts *JsonnetOpts) (string, error)
+
+	// SearchRepo searches the repository for an updated chart version
+	SearchRepo(chart, currVersion string, opts Opts) (ChartSearchVersions, error)
 }
 
 // PullOpts are additional, non-required options for Helm.Pull
@@ -39,6 +42,48 @@ type PullOpts struct {
 	ExtractDirectory string
 }
 
+// ChartSearchVersion represents a single chart version returned from the helm search repo command.
+type ChartSearchVersion struct {
+	// Name of the chart in the form of repo/chartName
+	Name string `json:"name,omitempty"`
+
+	// Version of the Helm chart
+	Version string `json:"version,omitempty"`
+
+	// Version of the application being deployed by the Helm chart
+	AppVersion string `json:"app_version,omitempty"`
+
+	// Description of the Helm chart
+	Description string `json:"description,omitempty"`
+}
+
+type ChartSearchVersions []ChartSearchVersion
+
+// RequiresVersionInfo represents a specific required chart and the information around the current
+// version and any upgrade information.
+type RequiresVersionInfo struct {
+	// Name of the required chart in the form of repo/chartName
+	Name string `json:"name,omitempty"`
+
+	// Directory information for the chart.
+	Directory string `json:"directory,omitempty"`
+
+	// The current version information of the required helm chart.
+	CurrentVersion string `json:"current_version,omitempty"`
+
+	// Boolean representing if the required chart is already up to date.
+	UsingLatestVersion bool `json:"using_latest_version"`
+
+	// The most up-to-date version information of the required helm chart.
+	LatestVersion ChartSearchVersion `json:"latest_version,omitempty"`
+
+	// The latest version information of the required helm chart that matches the current major version.
+	LatestMatchingMajorVersion ChartSearchVersion `json:"latest_matching_major_version,omitempty"`
+
+	// The latest version information of the required helm chart that matches the current minor version.
+	LatestMatchingMinorVersion ChartSearchVersion `json:"latest_matching_minor_version,omitempty"`
+}
+
 // Opts are additional, non-required options that all Helm operations accept
 type Opts struct {
 	Repositories []Repo
@@ -128,6 +173,63 @@ func (e ExecHelm) ChartExists(chart string, opts *JsonnetOpts) (string, error) {
 	return chart, nil
 }
 
+// Searches the helm repositories for the latest, the latest matching major, and the latest
+// matching minor versions for the given chart.
+func (e ExecHelm) SearchRepo(chart, currVersion string, opts Opts) (ChartSearchVersions, error) {
+	searchVersions := []string{
+		fmt.Sprintf(">=%s", currVersion), // Latest version X.X.X
+		fmt.Sprintf("^%s", currVersion),  // Latest matching major version 1.X.X
+		fmt.Sprintf("~%s", currVersion),  // Latest matching minor version 1.1.X
+	}
+
+	repoFile, err := writeRepoTmpFile(opts.Repositories)
+	if err != nil {
+		return nil, err
+	}
+	defer os.Remove(repoFile)
+
+	var chartVersions ChartSearchVersions
+	for _, versionRegex := range searchVersions {
+		var chartVersion ChartSearchVersions
+
+		// Vertical tabs are used as deliminators in table so \v is used to match exactly the chart.
+		// Helm search by default only returns the latest version matching the given version regex.
+		cmd := e.cmd("search", "repo",
+			"--repository-config", repoFile,
+			"--regexp", fmt.Sprintf("\v%s\v", chart),
+			"--version", versionRegex,
+			"-o", "json",
+		)
+		var errBuf bytes.Buffer
+		var outBuf bytes.Buffer
+		cmd.Stderr = &errBuf
+		cmd.Stdout = &outBuf
+
+		if err := cmd.Run(); err != nil {
+			return nil, fmt.Errorf("%s\n%s", errBuf.String(), err)
+		}
+
+		err = json.Unmarshal(outBuf.Bytes(), &chartVersion)
+		if err != nil {
+			return nil, err
+		}
+
+		if len(chartVersion) != 1 {
+			log.Debug().Msgf("helm search repo for %s did not return 1 version : %+v", chart, chartVersion)
+			chartVersions = append(chartVersions, ChartSearchVersion{
+				Name:        chart,
+				Version:     currVersion,
+				AppVersion:  "",
+				Description: "search did not return 1 version",
+			})
+		} else {
+			chartVersions = append(chartVersions, chartVersion...)
+		}
+	}
+
+	return chartVersions, nil
+}
+
 // cmd returns a prepared exec.Cmd to use the `helm` binary
 func (e ExecHelm) cmd(action string, args ...string) *exec.Cmd {
 	argv := []string{action}


Test patch:
diff --git a/pkg/helm/charts_test.go b/pkg/helm/charts_test.go
index 44bf7662..89f81a3c 100644
--- a/pkg/helm/charts_test.go
+++ b/pkg/helm/charts_test.go
@@ -278,3 +278,102 @@ repositories:
 	assert.NoError(t, err)
 	assert.Contains(t, string(chartContent), `version: 11.12.1`)
 }
+
+func TestChartsVersionCheck(t *testing.T) {
+	tempDir := t.TempDir()
+	c, err := InitChartfile(filepath.Join(tempDir, Filename))
+	require.NoError(t, err)
+
+	err = c.Add([]string{"stable/prometheus@11.12.0"}, "")
+	assert.NoError(t, err)
+
+	// Having multiple versions of the same chart should only return one update
+	err = c.Add([]string{"stable/prometheus@11.11.0:old"}, "")
+	assert.NoError(t, err)
+
+	chartVersions, err := c.VersionCheck("")
+	assert.NoError(t, err)
+
+	// stable/prometheus is deprecated so only the 11.12.1 should ever be returned as latest
+	latestPrometheusChartVersion := ChartSearchVersion{
+		Name:        "stable/prometheus",
+		Version:     "11.12.1",
+		AppVersion:  "2.20.1",
+		Description: "DEPRECATED Prometheus is a monitoring system and time series database.",
+	}
+	stableExpected := RequiresVersionInfo{
+		Name:                       "stable/prometheus",
+		Directory:                  "",
+		CurrentVersion:             "11.12.0",
+		UsingLatestVersion:         false,
+		LatestVersion:              latestPrometheusChartVersion,
+		LatestMatchingMajorVersion: latestPrometheusChartVersion,
+		LatestMatchingMinorVersion: latestPrometheusChartVersion,
+	}
+	oldExpected := RequiresVersionInfo{
+		Name:                       "stable/prometheus",
+		Directory:                  "old",
+		CurrentVersion:             "11.11.0",
+		UsingLatestVersion:         false,
+		LatestVersion:              latestPrometheusChartVersion,
+		LatestMatchingMajorVersion: latestPrometheusChartVersion,
+		LatestMatchingMinorVersion: ChartSearchVersion{
+			Name:        "stable/prometheus",
+			Version:     "11.11.1",
+			AppVersion:  "2.19.0",
+			Description: "Prometheus is a monitoring system and time series database.",
+		},
+	}
+	assert.Equal(t, 2, len(chartVersions))
+	assert.Equal(t, stableExpected, chartVersions["stable/prometheus@11.12.0"])
+	assert.Equal(t, oldExpected, chartVersions["stable/prometheus@11.11.0"])
+}
+
+func TestVersionCheckWithConfig(t *testing.T) {
+	tempDir := t.TempDir()
+	c, err := InitChartfile(filepath.Join(tempDir, Filename))
+	require.NoError(t, err)
+
+	// Don't want to commit credentials so we just verify the "private" repo reference will make
+	// use of this helm config since the InitChartfile does not have a reference to it.
+	require.NoError(t, os.WriteFile(filepath.Join(tempDir, "helmConfig.yaml"), []byte(`
+apiVersion: ""
+generated: "0001-01-01T00:00:00Z"
+repositories:
+- caFile: ""
+  certFile: ""
+  insecure_skip_tls_verify: false
+  keyFile: ""
+  name: private
+  pass_credentials_all: false
+  password: ""
+  url: https://charts.helm.sh/stable
+  username: ""
+`), 0644))
+	c.Manifest.Requires = append(c.Manifest.Requires, Requirement{
+		Chart:   "private/prometheus",
+		Version: "11.12.0",
+	})
+
+	chartVersions, err := c.VersionCheck(filepath.Join(tempDir, "helmConfig.yaml"))
+	assert.NoError(t, err)
+
+	// stable/prometheus is deprecated so only the 11.12.1 should ever be returned as latest
+	latestPrometheusChartVersion := ChartSearchVersion{
+		Name:        "private/prometheus",
+		Version:     "11.12.1",
+		AppVersion:  "2.20.1",
+		Description: "DEPRECATED Prometheus is a monitoring system and time series database.",
+	}
+	expected := RequiresVersionInfo{
+		Name:                       "private/prometheus",
+		Directory:                  "",
+		CurrentVersion:             "11.12.0",
+		UsingLatestVersion:         false,
+		LatestVersion:              latestPrometheusChartVersion,
+		LatestMatchingMajorVersion: latestPrometheusChartVersion,
+		LatestMatchingMinorVersion: latestPrometheusChartVersion,
+	}
+	assert.Equal(t, 1, len(chartVersions))
+	assert.Equal(t, expected, chartVersions["private/prometheus@11.12.0"])
+}
diff --git a/pkg/helm/jsonnet_test.go b/pkg/helm/jsonnet_test.go
index 0ea2ca0d..509ce186 100644
--- a/pkg/helm/jsonnet_test.go
+++ b/pkg/helm/jsonnet_test.go
@@ -44,6 +44,11 @@ func (m *MockHelm) ChartExists(chart string, opts *JsonnetOpts) (string, error)
 	return args.String(0), args.Error(1)
 }
 
+func (m *MockHelm) SearchRepo(chart, currVersion string, opts Opts) (ChartSearchVersions, error) {
+	args := m.Called(chart, currVersion, opts)
+	return args.Get(0).(ChartSearchVersions), args.Error(1)
+}
+
 func callNativeFunction(t *testing.T, expectedHelmTemplateOptions TemplateOpts, inputOptionsFromJsonnet map[string]interface{}) []string {
 	t.Helper()