Skip to content
2 changes: 2 additions & 0 deletions cmd/argocd-repo-server/commands/argocd_repo_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -155,6 +156,7 @@ func NewCommand() *cobra.Command {
OCIMediaTypes: ociMediaTypes,
EnableBuiltinGitConfig: enableBuiltinGitConfig,
HelmUserAgent: helmUserAgent,
ChartCacheExpiration: repoCacheExpiration,
}, askPassServer)
errors.CheckError(err)

Expand Down
5 changes: 5 additions & 0 deletions reposerver/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
Mangaal marked this conversation as resolved.

// ClusterRuntimeInfo holds cluster runtime information
type ClusterRuntimeInfo interface {
// GetApiVersions returns supported api versions
Expand Down
2 changes: 2 additions & 0 deletions reposerver/repository/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ type RepoServerInitConstants struct {
CMPUseManifestGeneratePaths bool
EnableBuiltinGitConfig bool
HelmUserAgent string
ChartCacheExpiration time.Duration
}

var manifestGenerateLock = sync.NewKeyLock()
Expand All @@ -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,
Expand Down
50 changes: 41 additions & 9 deletions util/helm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
}
Comment thread
Mangaal marked this conversation as resolved.

func fileExist(filePath string) (bool, error) {
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
Expand Down Expand Up @@ -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())
Expand Down
118 changes: 118 additions & 0 deletions util/helm/client_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package helm

import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/tls"
"encoding/json"
"fmt"
Expand All @@ -13,7 +15,9 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
}
Loading