Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add version-check command for vendored helm charts #1018

Merged
merged 4 commits into from
May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading