Skip to content

Commit

Permalink
Add version-check command for vendored helm charts (#1018)
Browse files Browse the repository at this point in the history
* Add version-check command for vendored helm charts
* Provide additional information in version check return. Return map of requirements rather than list
  • Loading branch information
SimKev2 authored May 2, 2024
1 parent e2ac5a4 commit 30dac00
Show file tree
Hide file tree
Showing 5 changed files with 291 additions and 11 deletions.
37 changes: 35 additions & 2 deletions cmd/tk/toolCharts.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand All @@ -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",
Expand All @@ -24,6 +27,7 @@ func chartsCmd() *cli.Command {
chartsAddRepoCmd(),
chartsVendorCmd(),
chartsConfigCmd(),
chartsVersionCheckCmd(),
)

return cmd
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down
59 changes: 50 additions & 9 deletions pkg/helm/charts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
99 changes: 99 additions & 0 deletions pkg/helm/charts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]"}, "")
assert.NoError(t, err)

// Having multiple versions of the same chart should only return one update
err = c.Add([]string{"stable/[email protected]: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/[email protected]"])
assert.Equal(t, oldExpected, chartVersions["stable/[email protected]"])
}

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/[email protected]"])
}
102 changes: 102 additions & 0 deletions pkg/helm/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand Down
Loading

0 comments on commit 30dac00

Please sign in to comment.