diff --git a/cmd/argocd-repo-server/commands/argocd_repo_server.go b/cmd/argocd-repo-server/commands/argocd_repo_server.go index 638bde6a94f8c..166ddcdd51079 100644 --- a/cmd/argocd-repo-server/commands/argocd_repo_server.go +++ b/cmd/argocd-repo-server/commands/argocd_repo_server.go @@ -113,6 +113,7 @@ func NewCommand() *cobra.Command { cache, err := cacheSrc() errors.CheckError(err) + repoCacheExpiration := cache.GetRepoCacheExpiration() maxCombinedDirectoryManifestsQuantity, err := resource.ParseQuantity(maxCombinedDirectoryManifestsSize) errors.CheckError(err) @@ -155,6 +156,7 @@ func NewCommand() *cobra.Command { OCIMediaTypes: ociMediaTypes, EnableBuiltinGitConfig: enableBuiltinGitConfig, HelmUserAgent: helmUserAgent, + ChartCacheExpiration: repoCacheExpiration, }, askPassServer) errors.CheckError(err) diff --git a/reposerver/cache/cache.go b/reposerver/cache/cache.go index a4d53f3138091..fa908ea0cb313 100644 --- a/reposerver/cache/cache.go +++ b/reposerver/cache/cache.go @@ -36,6 +36,11 @@ type Cache struct { revisionCacheLockTimeout time.Duration } +// GetRepoCacheExpiration returns the configured repo cache expiration duration. +func (c *Cache) GetRepoCacheExpiration() time.Duration { + return c.repoCacheExpiration +} + // ClusterRuntimeInfo holds cluster runtime information type ClusterRuntimeInfo interface { // GetApiVersions returns supported api versions diff --git a/reposerver/repository/repository.go b/reposerver/repository/repository.go index 7889f18aca7c5..2bed9fcb77150 100644 --- a/reposerver/repository/repository.go +++ b/reposerver/repository/repository.go @@ -126,6 +126,7 @@ type RepoServerInitConstants struct { CMPUseManifestGeneratePaths bool EnableBuiltinGitConfig bool HelmUserAgent string + ChartCacheExpiration time.Duration } var manifestGenerateLock = sync.NewKeyLock() @@ -152,6 +153,7 @@ func NewService(metricsServer *metrics.MetricsServer, cache *cache.Cache, initCo if initConstants.HelmUserAgent != "" { opts = append(opts, helm.WithUserAgent(initConstants.HelmUserAgent)) } + opts = append(opts, helm.WithChartCacheExpiration(initConstants.ChartCacheExpiration)) return helm.NewClientWithLock(repoURL, creds, sync.NewKeyLock(), enableOci, proxy, noProxy, opts...) }, initConstants: initConstants, diff --git a/util/helm/client.go b/util/helm/client.go index f84909090ffeb..52f59c2eaa89a 100644 --- a/util/helm/client.go +++ b/util/helm/client.go @@ -111,15 +111,16 @@ func NewClientWithLock(repoURL string, creds Creds, repoLock sync.KeyLock, enabl var _ Client = &nativeHelmChart{} type nativeHelmChart struct { - chartCachePaths utilio.TempPaths - repoURL string - creds Creds - repoLock sync.KeyLock - enableOci bool - indexCache indexCache - proxy string - noProxy string - customUserAgent string // Custom User-Agent string (optional) + chartCachePaths utilio.TempPaths + repoURL string + creds Creds + repoLock sync.KeyLock + enableOci bool + indexCache indexCache + proxy string + noProxy string + customUserAgent string // Custom User-Agent string (optional) + chartCacheExpiration time.Duration // Cache expiration for chart cache } // getUserAgent returns the User-Agent string to use for HTTP requests. @@ -134,6 +135,13 @@ func (c *nativeHelmChart) getUserAgent() string { return fmt.Sprintf("argocd-repo-server/%s (%s)", version.Version, version.Platform) } +// WithChartCacheExpiration sets the cache expiration duration for chart cache. +func WithChartCacheExpiration(expiration time.Duration) ClientOpts { + return func(c *nativeHelmChart) { + c.chartCacheExpiration = expiration + } +} + func fileExist(filePath string) (bool, error) { if _, err := os.Stat(filePath); err != nil { if os.IsNotExist(err) { @@ -202,6 +210,30 @@ func (c *nativeHelmChart) ExtractChart(chart string, version string, passCredent return "", nil, fmt.Errorf("error checking existence of cached chart path: %w", err) } + // if chart tar exists, check if it is expired based on cache expiration setting + if exists && c.chartCacheExpiration > 0 { + info, statErr := os.Stat(cachedChartPath) + if statErr != nil { + if !os.IsNotExist(statErr) { + _ = os.RemoveAll(tempDir) + return "", nil, fmt.Errorf("failed to get file info for cached chart path: %w", statErr) + } + exists = false + } + + if exists { + now := time.Now() + modTime := info.ModTime() + if modTime.After(now) || now.Sub(modTime) > c.chartCacheExpiration { + if removeErr := os.RemoveAll(cachedChartPath); removeErr != nil { + _ = os.RemoveAll(tempDir) + return "", nil, fmt.Errorf("error removing expired cached chart: %w", removeErr) + } + exists = false + } + } + } + if !exists { // create empty temp directory to extract chart from the registry tempDest, err := files.CreateTempDir(os.TempDir()) diff --git a/util/helm/client_test.go b/util/helm/client_test.go index fdc2f3c47c54a..15c86c08fe123 100644 --- a/util/helm/client_test.go +++ b/util/helm/client_test.go @@ -1,7 +1,9 @@ package helm import ( + "archive/tar" "bytes" + "compress/gzip" "crypto/tls" "encoding/json" "fmt" @@ -13,7 +15,9 @@ import ( "path/filepath" "slices" "strings" + "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -111,6 +115,90 @@ func Test_nativeHelmChart_ExtractChart_insecure(t *testing.T) { assert.True(t, info.IsDir()) } +func Test_nativeHelmChart_ExtractChartRespectsChartCacheExpiration(t *testing.T) { + const ( + chartName = "test-chart" + chartVersion = "1.0.0" + ) + + var ( + mu sync.Mutex + chartRequests int + chartArchive = testHelmChartArchive(t, chartName, chartVersion, "foo: first\n") + server *httptest.Server + ) + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/index.yaml": + w.Header().Set("Content-Type", "application/x-yaml") + _, _ = fmt.Fprintf(w, `apiVersion: v1 +entries: + %[1]s: + - apiVersion: v2 + name: %[1]s + version: %[2]s + urls: + - %[3]s/%[1]s-%[2]s.tgz +`, chartName, chartVersion, server.URL) + case "/" + chartName + "-" + chartVersion + ".tgz": + mu.Lock() + chartRequests++ + body := append([]byte(nil), chartArchive...) + mu.Unlock() + + w.Header().Set("Content-Type", "application/gzip") + _, _ = w.Write(body) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, HelmCreds{}, false, "", "", + WithChartPaths(utilio.NewRandomizedTempPaths(t.TempDir())), + WithChartCacheExpiration(time.Hour), + ) + nativeClient, ok := client.(*nativeHelmChart) + require.True(t, ok) + + extractValues := func() string { + t.Helper() + path, closer, err := client.ExtractChart(chartName, chartVersion, false, math.MaxInt64, false) + require.NoError(t, err) + defer utilio.Close(closer) + + values, err := os.ReadFile(filepath.Join(path, "values.yaml")) + require.NoError(t, err) + return string(values) + } + requireChartRequests := func(expected int) { + t.Helper() + mu.Lock() + defer mu.Unlock() + require.Equal(t, expected, chartRequests) + } + + require.Equal(t, "foo: first\n", extractValues()) + requireChartRequests(1) + + secondChartArchive := testHelmChartArchive(t, chartName, chartVersion, "foo: second\n") + mu.Lock() + chartArchive = secondChartArchive + mu.Unlock() + + require.Equal(t, "foo: first\n", extractValues()) + requireChartRequests(1) + + cachedChartPath, err := nativeClient.getCachedChartPath(chartName, chartVersion) + require.NoError(t, err) + expired := time.Now().Add(-2 * time.Hour) + require.NoError(t, os.Chtimes(cachedChartPath, expired, expired)) + + require.Equal(t, "foo: second\n", extractValues()) + requireChartRequests(2) +} + func Test_normalizeChartName(t *testing.T) { t.Run("Test non-slashed name", func(t *testing.T) { n := normalizeChartName("mychart") @@ -769,3 +857,33 @@ entries: {} t.Logf("Default User-Agent sent: %s", receivedUserAgent) }) } + +func testHelmChartArchive(t *testing.T, chartName, chartVersion, values string) []byte { + t.Helper() + + var archive bytes.Buffer + gzipWriter := gzip.NewWriter(&archive) + tarWriter := tar.NewWriter(gzipWriter) + + writeFile := func(name, contents string) { + t.Helper() + header := &tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(contents)), + } + require.NoError(t, tarWriter.WriteHeader(header)) + _, err := tarWriter.Write([]byte(contents)) + require.NoError(t, err) + } + + writeFile(filepath.ToSlash(filepath.Join(chartName, "Chart.yaml")), fmt.Sprintf(`apiVersion: v2 +name: %s +version: %s +`, chartName, chartVersion)) + writeFile(filepath.ToSlash(filepath.Join(chartName, "values.yaml")), values) + + require.NoError(t, tarWriter.Close()) + require.NoError(t, gzipWriter.Close()) + return archive.Bytes() +}