diff --git a/cmd/tk/toolCharts.go b/cmd/tk/toolCharts.go index 84e0fd5d5..cf032f1b8 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 73b692e26..e866bc068 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/charts_test.go b/pkg/helm/charts_test.go index 44bf76623..89f81a3cd 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/helm.go b/pkg/helm/helm.go index 407cd6bef..79d9b2c40 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} diff --git a/pkg/helm/jsonnet_test.go b/pkg/helm/jsonnet_test.go index 0ea2ca0d4..509ce1866 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()