Repository: grafana/tanka
Pull request #1010 (2024-03-15 18:51:55)

Update default k8s version to 1.29
Hello 👋

It seems that support for Kubernetes versions 1.20 through 1.25 were recently removed from [k8s-libsonnet](https://github.com/jsonnet-libs/k8s-libsonnet) by this commit: jsonnet-libs/k8s-libsonnet@90d0fae415cff9d0c089d60e9a478efee092c42e. Since then, `tk init` (without the `--k8s` flag set) fails by default since the files for 1.20 are no longer available. See example below.

```bash
$ tk init

GET https://github.com/jsonnet-libs/k8s-libsonnet/archive/bf9a62cfd32a58c071b8410bfcdec058475dd25e.tar.gz 200
panic: rename /Users/root/project/vendor/.tmp/28d5d0cf4e0847d16afe3035627683a62705765237/1.20 /Users/root/project/vendor/github.com/jsonnet-libs/k8s-libsonnet/1.20: no such file or directory
[...]
Installing k.libsonnet: exit status 2
```
I've proposed updating the `defaultK8sVersion` flag for the init command to instead use the latest stable version, 1.29.

Cheers!

CLAassistant: [![CLA assistant check](https://cla-assistant.io/pull/badge/not_signed)](https://cla-assistant.io/grafana/tanka?pullRequest=1010) <br/>Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our [Contributor License Agreement](https://cla-assistant.io/grafana/tanka?pullRequest=1010) before we can accept your contribution.<br/><sub>You have signed the CLA already but the status is still pending? Let us [recheck](https://cla-assistant.io/check/grafana/tanka?pullRequest=1010) it.</sub>

Patch:
diff --git a/Dockerfile b/Dockerfile
index f9eade38..5134a8fe 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # download kubectl
-FROM golang:1.22.1-alpine as kubectl
+FROM golang:1.22.2-alpine as kubectl
 RUN apk add --no-cache curl
 RUN export VERSION=$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt) &&\
     export OS=$(go env GOOS) && \
@@ -8,7 +8,7 @@ RUN export VERSION=$(curl -s https://storage.googleapis.com/kubernetes-release/r
     chmod +x /usr/local/bin/kubectl
 
 # build jsonnet-bundler
-FROM golang:1.22.1-alpine as jb
+FROM golang:1.22.2-alpine as jb
 WORKDIR /tmp
 RUN apk add --no-cache git make bash &&\
     git clone https://github.com/jsonnet-bundler/jsonnet-bundler &&\
@@ -17,7 +17,7 @@ RUN apk add --no-cache git make bash &&\
     make static &&\
     mv _output/jb /usr/local/bin/jb
 
-FROM golang:1.22.1-alpine as helm
+FROM golang:1.22.2-alpine as helm
 WORKDIR /tmp/helm
 RUN apk add --no-cache jq curl
 RUN export TAG=$(curl --silent "https://api.github.com/repos/helm/helm/releases/latest" | jq -r .tag_name) &&\
@@ -26,7 +26,7 @@ RUN export TAG=$(curl --silent "https://api.github.com/repos/helm/helm/releases/
     curl -SL "https://get.helm.sh/helm-${TAG}-${OS}-${ARCH}.tar.gz" > helm.tgz && \
     tar -xvf helm.tgz --strip-components=1
 
-FROM golang:1.22.1-alpine as kustomize
+FROM golang:1.22.2-alpine as kustomize
 WORKDIR /tmp/kustomize
 RUN apk add --no-cache jq curl
 # Get the latest version of kustomize
@@ -39,7 +39,7 @@ RUN export TAG=$(curl --silent "https://api.github.com/repos/kubernetes-sigs/kus
     curl -SL "https://github.com/kubernetes-sigs/kustomize/releases/download/${TAG}/kustomize_${VERSION_TAG}_${OS}_${ARCH}.tar.gz" > kustomize.tgz && \
     tar -xvf kustomize.tgz
 
-FROM golang:1.22.1 as build
+FROM golang:1.22.2 as build
 WORKDIR /app
 COPY . .
 RUN make static
diff --git a/cmd/tk/init.go b/cmd/tk/init.go
index 32a5f3d9..17042306 100644
--- a/cmd/tk/init.go
+++ b/cmd/tk/init.go
@@ -12,7 +12,7 @@ import (
 	"github.com/grafana/tanka/pkg/spec/v1alpha1"
 )
 
-const defaultK8sVersion = "1.20"
+const defaultK8sVersion = "1.29"
 
 // initCmd creates a new application
 func initCmd() *cli.Command {
diff --git a/cmd/tk/toolCharts.go b/cmd/tk/toolCharts.go
index 47b80933..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,6 +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", "", repoConfigFlagUsage)
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
 		c, err := loadChartfile()
@@ -42,7 +47,7 @@ func chartsVendorCmd() *cli.Command {
 			return err
 		}
 
-		return c.Vendor(*prune)
+		return c.Vendor(*prune, *repoConfigPath)
 	}
 
 	return cmd
@@ -53,6 +58,7 @@ func chartsAddCmd() *cli.Command {
 		Use:   "add [chart@version] [...]",
 		Short: "Adds Charts to the chartfile",
 	}
+	repoConfigPath := cmd.Flags().String("repository-config", "", repoConfigFlagUsage)
 
 	cmd.Run = func(cmd *cli.Command, args []string) error {
 		c, err := loadChartfile()
@@ -60,7 +66,7 @@ func chartsAddCmd() *cli.Command {
 			return err
 		}
 
-		return c.Add(args)
+		return c.Add(args, *repoConfigPath)
 	}
 
 	return cmd
@@ -141,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/go.mod b/go.mod
index 538cd8c2..8e6b435f 100644
--- a/go.mod
+++ b/go.mod
@@ -19,8 +19,8 @@ require (
 	github.com/stretchr/objx v0.5.2
 	github.com/stretchr/testify v1.9.0
 	github.com/thoas/go-funk v0.9.3
-	golang.org/x/term v0.17.0
-	golang.org/x/text v0.13.0
+	golang.org/x/term v0.20.0
+	golang.org/x/text v0.15.0
 	gopkg.in/yaml.v2 v2.4.0
 	gopkg.in/yaml.v3 v3.0.1
 	k8s.io/apimachinery v0.28.3
@@ -46,6 +46,6 @@ require (
 	github.com/shopspring/decimal v1.3.1 // indirect
 	github.com/spf13/cast v1.4.1 // indirect
 	golang.org/x/crypto v0.3.0 // indirect
-	golang.org/x/sys v0.17.0 // indirect
+	golang.org/x/sys v0.20.0 // indirect
 	k8s.io/klog/v2 v2.100.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 265f4386..e14a24be 100644
--- a/go.sum
+++ b/go.sum
@@ -102,19 +102,19 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/pkg/helm/charts.go b/pkg/helm/charts.go
index ee27907e..e866bc06 100644
--- a/pkg/helm/charts.go
+++ b/pkg/helm/charts.go
@@ -58,6 +58,29 @@ func LoadChartfile(projectRoot string) (*Charts, error) {
 	return charts, nil
 }
 
+// LoadHelmRepoConfig reads in a helm config file
+func LoadHelmRepoConfig(repoConfigPath string) (*ConfigFile, error) {
+	// make sure path is valid
+	repoPath, err := filepath.Abs(repoConfigPath)
+	if err != nil {
+		return nil, err
+	}
+
+	// open repo config file
+	data, err := os.ReadFile(repoPath)
+	if err != nil {
+		return nil, err
+	}
+
+	// parse the file non-strictly to account for any minor config changes
+	rc := &ConfigFile{}
+	if err := yaml.Unmarshal(data, rc); err != nil {
+		return nil, err
+	}
+
+	return rc, nil
+}
+
 // Charts exposes the central Chartfile management functions
 type Charts struct {
 	// Manifest are the chartfile.yaml contents. It holds data about the developers intentions
@@ -89,12 +112,17 @@ func (c Charts) ManifestFile() string {
 
 // Vendor pulls all Charts specified in the manifest into the local charts
 // directory. It fetches the repository index before doing so.
-func (c Charts) Vendor(prune bool) error {
+func (c Charts) Vendor(prune bool, repoConfigPath string) error {
 	dir := c.ChartDir()
 	if err := os.MkdirAll(dir, os.ModePerm); err != nil {
 		return err
 	}
 
+	repositories, err := c.getRepositories(repoConfigPath)
+	if err != nil {
+		return err
+	}
+
 	// Check that there are no output conflicts before vendoring
 	if err := c.Manifest.Requires.Validate(); err != nil {
 		return err
@@ -154,19 +182,19 @@ func (c Charts) Vendor(prune bool) 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
@@ -199,7 +227,7 @@ func (c Charts) Vendor(prune bool) error {
 
 // Add adds every Chart in reqs to the Manifest after validation, and runs
 // Vendor afterwards
-func (c *Charts) Add(reqs []string) error {
+func (c *Charts) Add(reqs []string, repoConfigPath string) error {
 	log.Info().Msgf("Adding %v Charts ...", len(reqs))
 
 	// parse new charts, append in memory
@@ -238,7 +266,7 @@ func (c *Charts) Add(reqs []string) error {
 
 	// worked fine? vendor it
 	log.Info().Msgf("Added %v Charts to helmfile.yaml. Vendoring ...", added)
-	return c.Vendor(false)
+	return c.Vendor(false, repoConfigPath)
 }
 
 func (c *Charts) AddRepos(repos ...Repo) error {
@@ -271,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}
diff --git a/pkg/helm/jsonnet.go b/pkg/helm/jsonnet.go
index f7cfe156..e93fc999 100644
--- a/pkg/helm/jsonnet.go
+++ b/pkg/helm/jsonnet.go
@@ -1,17 +1,24 @@
 package helm
 
 import (
+	"crypto/sha256"
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
+	"sync"
 
 	"github.com/google/go-jsonnet"
 	"github.com/google/go-jsonnet/ast"
 	"github.com/grafana/tanka/pkg/kubernetes/manifest"
+	"github.com/rs/zerolog/log"
 )
 
 // DefaultNameFormat to use when no nameFormat is supplied
 const DefaultNameFormat = `{{ print .kind "_" .metadata.name | snakecase }}`
 
+// helmTemplateCache caches the inline environments' rendered helm templates.
+var helmTemplateCache sync.Map
+
 // JsonnetOpts are additional properties the consumer of the native func might
 // pass.
 type JsonnetOpts struct {
@@ -57,6 +64,16 @@ func NativeFunc(h Helm) *jsonnet.NativeFunction {
 				return nil, fmt.Errorf("helmTemplate: Failed to find a chart at '%s': %s. See https://tanka.dev/helm#failed-to-find-chart", chart, err)
 			}
 
+			// check if resources exist in cache
+			helmKey, err := templateKey(name, chartpath, opts.TemplateOpts)
+			if err != nil {
+				return nil, err
+			}
+			if entry, ok := helmTemplateCache.Load(helmKey); ok {
+				log.Debug().Msgf("Using cached template for %s", name)
+				return entry, nil
+			}
+
 			// render resources
 			list, err := h.Template(name, chart, opts.TemplateOpts)
 			if err != nil {
@@ -69,11 +86,25 @@ func NativeFunc(h Helm) *jsonnet.NativeFunction {
 				return nil, err
 			}
 
+			helmTemplateCache.Store(helmKey, out)
 			return out, nil
 		},
 	}
 }
 
+// templateKey returns the key identifier used in the template cache for the given helm chart.
+func templateKey(chartName string, chartPath string, opts TemplateOpts) (string, error) {
+	hasher := sha256.New()
+	hasher.Write([]byte(chartName))
+	hasher.Write([]byte(chartPath))
+	valuesBytes, err := json.Marshal(opts)
+	if err != nil {
+		return "", err
+	}
+	hasher.Write(valuesBytes)
+	return base64.URLEncoding.EncodeToString(hasher.Sum(nil)), nil
+}
+
 func parseOpts(data interface{}) (*JsonnetOpts, error) {
 	c, err := json.Marshal(data)
 	if err != nil {
diff --git a/pkg/helm/spec.go b/pkg/helm/spec.go
index edd449c1..c519ae86 100644
--- a/pkg/helm/spec.go
+++ b/pkg/helm/spec.go
@@ -33,6 +33,19 @@ type Chartfile struct {
 	Directory string `json:"directory,omitempty"`
 }
 
+// ConfigFile represents the default Helm config structure to be used in place of the chartfile
+// Repositories if supplied.
+type ConfigFile struct {
+	// Version of the Helm repo config schema
+	APIVersion string `json:"apiVersion"`
+
+	// The datetime of when this repo config was generated
+	Generated string `json:"generated"`
+
+	// Repositories to source from
+	Repositories Repos `json:"repositories"`
+}
+
 // Repo describes a single Helm repository
 type Repo struct {
 	Name     string `json:"name,omitempty"`


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 24969d89..cde45919 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -21,7 +21,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - uses: ./.github/actions/setup-goversion
-      - uses: azure/setup-helm@v3
+      - uses: azure/setup-helm@v4
         with:
           version: '3.13.1' 
       - name: Install jsonnet
diff --git a/pkg/helm/charts_test.go b/pkg/helm/charts_test.go
index 834a45cf..89f81a3c 100644
--- a/pkg/helm/charts_test.go
+++ b/pkg/helm/charts_test.go
@@ -88,28 +88,47 @@ func TestAdd(t *testing.T) {
 	c, err := InitChartfile(filepath.Join(tempDir, Filename))
 	require.NoError(t, err)
 
-	err = c.Add([]string{"stable/prometheus@11.12.1"})
+	err = c.Add([]string{"stable/prometheus@11.12.1"}, "")
 	assert.NoError(t, err)
 
 	// Adding again the same chart
-	err = c.Add([]string{"stable/prometheus@11.12.1"})
+	err = c.Add([]string{"stable/prometheus@11.12.1"}, "")
 	assert.EqualError(t, err, "1 Chart(s) were skipped. Please check above logs for details")
 
 	// Adding a chart with a different version to the same path, causes a conflict
-	err = c.Add([]string{"stable/prometheus@11.12.0"})
+	err = c.Add([]string{"stable/prometheus@11.12.0"}, "")
 	assert.EqualError(t, err, `Validation errors:
  - output directory "prometheus" is used twice, by charts "stable/prometheus@11.12.1" and "stable/prometheus@11.12.0"`)
 
 	// Add a chart with a specific extract directory
-	err = c.Add([]string{"stable/prometheus@11.12.0:prometheus-11.12.0"})
+	err = c.Add([]string{"stable/prometheus@11.12.0:prometheus-11.12.0"}, "")
+	assert.NoError(t, err)
+
+	// Add a chart while specifying a helm repo config file
+	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))
+	err = c.Add([]string{"private/prometheus@11.12.1:private-11.12.1"}, filepath.Join(tempDir, "helmConfig.yaml"))
 	assert.NoError(t, err)
 
 	// Check file contents
 	listResult, err := os.ReadDir(filepath.Join(tempDir, "charts"))
 	assert.NoError(t, err)
-	assert.Equal(t, 2, len(listResult))
-	assert.Equal(t, "prometheus", listResult[0].Name())
-	assert.Equal(t, "prometheus-11.12.0", listResult[1].Name())
+	assert.Equal(t, 3, len(listResult))
+	assert.Equal(t, "private-11.12.1", listResult[0].Name())
+	assert.Equal(t, "prometheus", listResult[1].Name())
+	assert.Equal(t, "prometheus-11.12.0", listResult[2].Name())
 
 	chartContent, err := os.ReadFile(filepath.Join(tempDir, "charts", "prometheus", "Chart.yaml"))
 	assert.NoError(t, err)
@@ -118,6 +137,10 @@ func TestAdd(t *testing.T) {
 	chartContent, err = os.ReadFile(filepath.Join(tempDir, "charts", "prometheus-11.12.0", "Chart.yaml"))
 	assert.NoError(t, err)
 	assert.Contains(t, string(chartContent), `version: 11.12.0`)
+
+	chartContent, err = os.ReadFile(filepath.Join(tempDir, "charts", "private-11.12.1", "Chart.yaml"))
+	assert.NoError(t, err)
+	assert.Contains(t, string(chartContent), `version: 11.12.1`)
 }
 
 func TestAddOCI(t *testing.T) {
@@ -128,7 +151,7 @@ func TestAddOCI(t *testing.T) {
 	err = c.AddRepos(Repo{Name: "karpenter", URL: "oci://public.ecr.aws/karpenter"})
 	assert.NoError(t, err)
 
-	err = c.Add([]string{"karpenter/karpenter@v0.27.1"})
+	err = c.Add([]string{"karpenter/karpenter@v0.27.1"}, "")
 	assert.NoError(t, err)
 
 	// Check file contents
@@ -143,7 +166,7 @@ func TestRevendorDeletedFiles(t *testing.T) {
 	c, err := InitChartfile(filepath.Join(tempDir, Filename))
 	require.NoError(t, err)
 
-	err = c.Add([]string{"stable/prometheus@11.12.1"})
+	err = c.Add([]string{"stable/prometheus@11.12.1"}, "")
 	assert.NoError(t, err)
 
 	// Check file contents
@@ -153,7 +176,7 @@ func TestRevendorDeletedFiles(t *testing.T) {
 
 	// Delete the whole dir and revendor
 	require.NoError(t, os.RemoveAll(filepath.Join(tempDir, "charts", "prometheus")))
-	assert.NoError(t, c.Vendor(true))
+	assert.NoError(t, c.Vendor(true, ""))
 
 	// Check file contents
 	chartContent, err = os.ReadFile(filepath.Join(tempDir, "charts", "prometheus", "Chart.yaml"))
@@ -162,7 +185,7 @@ func TestRevendorDeletedFiles(t *testing.T) {
 
 	// Delete just the Chart.yaml and revendor
 	require.NoError(t, os.Remove(filepath.Join(tempDir, "charts", "prometheus", "Chart.yaml")))
-	assert.NoError(t, c.Vendor(true))
+	assert.NoError(t, c.Vendor(true, ""))
 
 	// Check file contents
 	chartContent, err = os.ReadFile(filepath.Join(tempDir, "charts", "prometheus", "Chart.yaml"))
@@ -178,17 +201,17 @@ func TestPrune(t *testing.T) {
 			require.NoError(t, err)
 
 			// Add a chart
-			require.NoError(t, c.Add([]string{"stable/prometheus@11.12.1"}))
+			require.NoError(t, c.Add([]string{"stable/prometheus@11.12.1"}, ""))
 
 			// Add a chart with a directory
-			require.NoError(t, c.Add([]string{"stable/prometheus@11.12.1:custom-dir"}))
+			require.NoError(t, c.Add([]string{"stable/prometheus@11.12.1:custom-dir"}, ""))
 
 			// Add unrelated files and folders
 			require.NoError(t, os.WriteFile(filepath.Join(tempDir, "charts", "foo.txt"), []byte("foo"), 0644))
 			require.NoError(t, os.Mkdir(filepath.Join(tempDir, "charts", "foo"), 0755))
 			require.NoError(t, os.WriteFile(filepath.Join(tempDir, "charts", "foo", "Chart.yaml"), []byte("foo"), 0644))
 
-			require.NoError(t, c.Vendor(prune))
+			require.NoError(t, c.Vendor(prune, ""))
 
 			// Check if files are pruned
 			listResult, err := os.ReadDir(filepath.Join(tempDir, "charts"))
@@ -217,7 +240,140 @@ func TestInvalidChartName(t *testing.T) {
 		Version: "1.0.0",
 	})
 
-	err = c.Vendor(false)
+	err = c.Vendor(false, "")
 	assert.EqualError(t, err, `Validation errors:
  - Chart name "noslash" is not valid. Expecting a repo/name format.`)
 }
+
+func TestConfigFileOption(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.1",
+	})
+
+	err = c.Vendor(false, filepath.Join(tempDir, "helmConfig.yaml"))
+	assert.NoError(t, err)
+
+	chartContent, err := os.ReadFile(filepath.Join(tempDir, "charts", "prometheus", "Chart.yaml"))
+	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 7607e836..509ce186 100644
--- a/pkg/helm/jsonnet_test.go
+++ b/pkg/helm/jsonnet_test.go
@@ -10,6 +10,7 @@ import (
 )
 
 const calledFrom = "/my/path/here"
+const kubeVersion = "1.18.0"
 
 type MockHelm struct {
 	mock.Mock
@@ -43,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()
 
@@ -88,8 +94,6 @@ func callNativeFunction(t *testing.T, expectedHelmTemplateOptions TemplateOpts,
 // TestDefaultCommandineFlagsIncludeCrds tests that the includeCrds flag is set
 // to true by default
 func TestDefaultCommandLineFlagsIncludeCrds(t *testing.T) {
-	kubeVersion := "1.18.0"
-
 	// we will check that the template function is called with these options,
 	// i.e. that includeCrds got set to true. This is not us passing an input,
 	// we are asserting here that the template function is called with these
@@ -116,8 +120,6 @@ func TestDefaultCommandLineFlagsIncludeCrds(t *testing.T) {
 // TestIncludeCrdsFalse tests that the includeCrds flag is can be set to false,
 // and this makes it to the helm.Template() method call
 func TestIncludeCrdsFalse(t *testing.T) {
-	kubeVersion := "1.18.0"
-
 	// we will check that the template function is called with these options,
 	// i.e. that includeCrds got set to false. This is not us passing an input,
 	// we are asserting here that the template function is called with these
@@ -140,3 +142,121 @@ func TestIncludeCrdsFalse(t *testing.T) {
 	// `helm template` don't contain the --include-crds flag
 	require.NotContains(t, args, "--include-crds")
 }
+
+// TestTemplateCachingWorks tests that calling template with the same chart and values twice will
+// use the cached version the second time.
+func TestTemplateCachingWorks(t *testing.T) {
+	values := map[string]interface{}{"testkey": "testvalue"}
+	expectedHelmTemplateOptions := TemplateOpts{
+		Values:      values,
+		KubeVersion: kubeVersion,
+		IncludeCRDs: true,
+	}
+	inputOptionsFromJsonnet := make(map[string]interface{})
+	inputOptionsFromJsonnet["values"] = values
+	inputOptionsFromJsonnet["calledFrom"] = calledFrom
+	inputOptionsFromJsonnet["kubeVersion"] = kubeVersion
+
+	helmMock := &MockHelm{}
+	// ChartExists called on both function calls, only template is cached
+	helmMock.On(
+		"ChartExists",
+		"exampleChartPath",
+		mock.AnythingOfType("*helm.JsonnetOpts")).
+		Return("/full/chart/path", nil).
+		Twice()
+	// this verifies that the helmMock.Template() method is called with the
+	// correct arguments and only a single time.
+	helmMock.On("Template", "exampleChartName", "/full/chart/path", expectedHelmTemplateOptions).
+		Return(manifest.List{}, nil).
+		Once()
+
+	nf := NativeFunc(helmMock)
+	require.NotNil(t, nf)
+
+	// the mandatory parameters to helm.template() in Jsonnet
+	params := []string{
+		"exampleChartName",
+		"exampleChartPath",
+	}
+
+	// mandatory parameters + the k-v pairs from the Jsonnet input
+	paramsInterface := make([]interface{}, 3)
+	paramsInterface[0] = params[0]
+	paramsInterface[1] = params[1]
+	paramsInterface[2] = inputOptionsFromJsonnet
+
+	_, err := nf.Func(paramsInterface)
+	firstCommandArgs := helmMock.TestData().Get("templateCommandArgs").StringSlice()
+	require.NoError(t, err)
+	_, err = nf.Func(paramsInterface)
+	secondCommandArgs := helmMock.TestData().Get("templateCommandArgs").StringSlice()
+	require.NoError(t, err)
+
+	helmMock.AssertExpectations(t)
+
+	// Verify command line args are same between the two calls
+	require.Equal(t, firstCommandArgs, secondCommandArgs)
+}
+
+type templateData struct {
+	chartName string
+	chartPath string
+	opts      TemplateOpts
+}
+
+var templateTestCases = []struct {
+	name       string
+	data       templateData
+	errMessage string
+}{
+	{
+		name: "emptyData",
+		data: templateData{
+			chartName: "testChart",
+			chartPath: "./chart/path",
+			opts:      TemplateOpts{},
+		},
+	},
+	{
+		name: "fullData",
+		data: templateData{
+			chartName: "bigChart",
+			chartPath: "./chart/bigPath",
+			opts: TemplateOpts{
+				map[string]interface{}{
+					"installCRDs": true,
+					"multitenancy": map[string]interface{}{
+						"enabled":               false,
+						"defaultServiceAccount": "default",
+						"privileged":            false,
+					},
+					"clusterDomain": "cluster.local",
+					"cli": map[string]interface{}{
+						"image":        "test-image.io",
+						"nodeSelector": map[string]interface{}{},
+						"tolerations":  []interface{}{},
+					},
+					"baz": []int32{12, 13},
+				},
+				[]string{"asdf", "qwer", "zxcv"},
+				true,
+				false,
+				"version",
+				"namespace",
+				false,
+			},
+		},
+	},
+}
+
+func BenchmarkTemplateKey(b *testing.B) {
+	for _, c := range templateTestCases {
+		b.Run(c.name, func(b *testing.B) {
+			for i := 0; i < b.N; i++ {
+				// nolint:errcheck
+				templateKey(c.data.chartName, c.data.chartPath, c.data.opts)
+			}
+		})
+	}
+}