github.com/argoproj/argo-cd/v3@v3.2.1/util/helm/client.go (about) 1 package helm 2 3 import ( 4 "bytes" 5 "context" 6 "crypto/tls" 7 "crypto/x509" 8 "encoding/json" 9 "errors" 10 "fmt" 11 "io" 12 "net/http" 13 "net/url" 14 "os" 15 "os/exec" 16 "path" 17 "path/filepath" 18 "strings" 19 "time" 20 21 executil "github.com/argoproj/argo-cd/v3/util/exec" 22 23 "github.com/argoproj/pkg/v2/sync" 24 log "github.com/sirupsen/logrus" 25 "gopkg.in/yaml.v2" 26 "oras.land/oras-go/v2/registry/remote" 27 "oras.land/oras-go/v2/registry/remote/auth" 28 "oras.land/oras-go/v2/registry/remote/credentials" 29 30 "github.com/argoproj/argo-cd/v3/util/cache" 31 utilio "github.com/argoproj/argo-cd/v3/util/io" 32 "github.com/argoproj/argo-cd/v3/util/io/files" 33 "github.com/argoproj/argo-cd/v3/util/proxy" 34 ) 35 36 var ( 37 globalLock = sync.NewKeyLock() 38 indexLock = sync.NewKeyLock() 39 40 ErrOCINotEnabled = errors.New("could not perform the action when oci is not enabled") 41 ) 42 43 type indexCache interface { 44 SetHelmIndex(repo string, indexData []byte) error 45 GetHelmIndex(repo string, indexData *[]byte) error 46 } 47 48 type Client interface { 49 CleanChartCache(chart string, version string) error 50 ExtractChart(chart string, version string, passCredentials bool, manifestMaxExtractedSize int64, disableManifestMaxExtractedSize bool) (string, utilio.Closer, error) 51 GetIndex(noCache bool, maxIndexSize int64) (*Index, error) 52 GetTags(chart string, noCache bool) ([]string, error) 53 TestHelmOCI() (bool, error) 54 } 55 56 type ClientOpts func(c *nativeHelmChart) 57 58 func WithIndexCache(indexCache indexCache) ClientOpts { 59 return func(c *nativeHelmChart) { 60 c.indexCache = indexCache 61 } 62 } 63 64 func WithChartPaths(chartPaths utilio.TempPaths) ClientOpts { 65 return func(c *nativeHelmChart) { 66 c.chartCachePaths = chartPaths 67 } 68 } 69 70 func NewClient(repoURL string, creds Creds, enableOci bool, proxy string, noProxy string, opts ...ClientOpts) Client { 71 return NewClientWithLock(repoURL, creds, globalLock, enableOci, proxy, noProxy, opts...) 72 } 73 74 func NewClientWithLock(repoURL string, creds Creds, repoLock sync.KeyLock, enableOci bool, proxy string, noProxy string, opts ...ClientOpts) Client { 75 c := &nativeHelmChart{ 76 repoURL: repoURL, 77 creds: creds, 78 repoLock: repoLock, 79 enableOci: enableOci, 80 proxy: proxy, 81 noProxy: noProxy, 82 chartCachePaths: utilio.NewRandomizedTempPaths(os.TempDir()), 83 } 84 for i := range opts { 85 opts[i](c) 86 } 87 return c 88 } 89 90 var _ Client = &nativeHelmChart{} 91 92 type nativeHelmChart struct { 93 chartCachePaths utilio.TempPaths 94 repoURL string 95 creds Creds 96 repoLock sync.KeyLock 97 enableOci bool 98 indexCache indexCache 99 proxy string 100 noProxy string 101 } 102 103 func fileExist(filePath string) (bool, error) { 104 if _, err := os.Stat(filePath); err != nil { 105 if os.IsNotExist(err) { 106 return false, nil 107 } 108 return false, fmt.Errorf("error checking file existence for %s: %w", filePath, err) 109 } 110 return true, nil 111 } 112 113 func (c *nativeHelmChart) CleanChartCache(chart string, version string) error { 114 cachePath, err := c.getCachedChartPath(chart, version) 115 if err != nil { 116 return fmt.Errorf("error getting cached chart path: %w", err) 117 } 118 if err := os.RemoveAll(cachePath); err != nil { 119 return fmt.Errorf("error removing chart cache at %s: %w", cachePath, err) 120 } 121 return nil 122 } 123 124 func untarChart(tempDir string, cachedChartPath string, manifestMaxExtractedSize int64, disableManifestMaxExtractedSize bool) error { 125 if disableManifestMaxExtractedSize { 126 cmd := exec.Command("tar", "-zxvf", cachedChartPath) 127 cmd.Dir = tempDir 128 _, err := executil.Run(cmd) 129 if err != nil { 130 return fmt.Errorf("error executing tar command: %w", err) 131 } 132 return nil 133 } 134 reader, err := os.Open(cachedChartPath) 135 if err != nil { 136 return fmt.Errorf("error opening cached chart path %s: %w", cachedChartPath, err) 137 } 138 return files.Untgz(tempDir, reader, manifestMaxExtractedSize, false) 139 } 140 141 func (c *nativeHelmChart) ExtractChart(chart string, version string, passCredentials bool, manifestMaxExtractedSize int64, disableManifestMaxExtractedSize bool) (string, utilio.Closer, error) { 142 // always use Helm V3 since we don't have chart content to determine correct Helm version 143 helmCmd, err := NewCmdWithVersion("", c.enableOci, c.proxy, c.noProxy) 144 if err != nil { 145 return "", nil, fmt.Errorf("error creating Helm command: %w", err) 146 } 147 defer helmCmd.Close() 148 149 // throw away temp directory that stores extracted chart and should be deleted as soon as no longer needed by returned closer 150 tempDir, err := files.CreateTempDir(os.TempDir()) 151 if err != nil { 152 return "", nil, fmt.Errorf("error creating temporary directory: %w", err) 153 } 154 155 cachedChartPath, err := c.getCachedChartPath(chart, version) 156 if err != nil { 157 _ = os.RemoveAll(tempDir) 158 return "", nil, fmt.Errorf("error getting cached chart path: %w", err) 159 } 160 161 c.repoLock.Lock(cachedChartPath) 162 defer c.repoLock.Unlock(cachedChartPath) 163 164 // check if chart tar is already downloaded 165 exists, err := fileExist(cachedChartPath) 166 if err != nil { 167 _ = os.RemoveAll(tempDir) 168 return "", nil, fmt.Errorf("error checking existence of cached chart path: %w", err) 169 } 170 171 if !exists { 172 // create empty temp directory to extract chart from the registry 173 tempDest, err := files.CreateTempDir(os.TempDir()) 174 if err != nil { 175 _ = os.RemoveAll(tempDir) 176 return "", nil, fmt.Errorf("error creating temporary destination directory: %w", err) 177 } 178 defer func() { _ = os.RemoveAll(tempDest) }() 179 180 if c.enableOci { 181 helmPassword, err := c.creds.GetPassword() 182 if err != nil { 183 return "", nil, fmt.Errorf("failed to get password for helm registry: %w", err) 184 } 185 if helmPassword != "" && c.creds.GetUsername() != "" { 186 _, err = helmCmd.RegistryLogin(c.repoURL, c.creds) 187 if err != nil { 188 _ = os.RemoveAll(tempDir) 189 return "", nil, fmt.Errorf("error logging into OCI registry: %w", err) 190 } 191 192 defer func() { 193 _, _ = helmCmd.RegistryLogout(c.repoURL, c.creds) 194 }() 195 } 196 197 // 'helm pull' ensures that chart is downloaded into temp directory 198 _, err = helmCmd.PullOCI(c.repoURL, chart, version, tempDest, c.creds) 199 if err != nil { 200 _ = os.RemoveAll(tempDir) 201 return "", nil, fmt.Errorf("error pulling OCI chart: %w", err) 202 } 203 } else { 204 _, err = helmCmd.Fetch(c.repoURL, chart, version, tempDest, c.creds, passCredentials) 205 if err != nil { 206 _ = os.RemoveAll(tempDir) 207 return "", nil, fmt.Errorf("error fetching chart: %w", err) 208 } 209 } 210 211 // 'helm pull/fetch' file downloads chart into the tgz file and we move that to where we want it 212 infos, err := os.ReadDir(tempDest) 213 if err != nil { 214 return "", nil, fmt.Errorf("error reading directory %s: %w", tempDest, err) 215 } 216 if len(infos) != 1 { 217 return "", nil, fmt.Errorf("expected 1 file, found %v", len(infos)) 218 } 219 220 chartFilePath := filepath.Join(tempDest, infos[0].Name()) 221 222 err = os.Rename(chartFilePath, cachedChartPath) 223 if err != nil { 224 return "", nil, fmt.Errorf("error renaming file from %s to %s: %w", chartFilePath, cachedChartPath, err) 225 } 226 } 227 228 err = untarChart(tempDir, cachedChartPath, manifestMaxExtractedSize, disableManifestMaxExtractedSize) 229 if err != nil { 230 _ = os.RemoveAll(tempDir) 231 return "", nil, fmt.Errorf("error untarring chart: %w", err) 232 } 233 return path.Join(tempDir, normalizeChartName(chart)), utilio.NewCloser(func() error { 234 return os.RemoveAll(tempDir) 235 }), nil 236 } 237 238 func (c *nativeHelmChart) GetIndex(noCache bool, maxIndexSize int64) (*Index, error) { 239 indexLock.Lock(c.repoURL) 240 defer indexLock.Unlock(c.repoURL) 241 242 var data []byte 243 if !noCache && c.indexCache != nil { 244 if err := c.indexCache.GetHelmIndex(c.repoURL, &data); err != nil && !errors.Is(err, cache.ErrCacheMiss) { 245 log.Warnf("Failed to load index cache for repo: %s: %v", c.repoURL, err) 246 } 247 } 248 249 if len(data) == 0 { 250 start := time.Now() 251 var err error 252 data, err = c.loadRepoIndex(maxIndexSize) 253 if err != nil { 254 return nil, fmt.Errorf("error loading repo index: %w", err) 255 } 256 log.WithFields(log.Fields{"seconds": time.Since(start).Seconds()}).Info("took to get index") 257 258 if c.indexCache != nil { 259 if err := c.indexCache.SetHelmIndex(c.repoURL, data); err != nil { 260 log.Warnf("Failed to store index cache for repo: %s: %v", c.repoURL, err) 261 } 262 } 263 } 264 265 index := &Index{} 266 err := yaml.NewDecoder(bytes.NewBuffer(data)).Decode(index) 267 if err != nil { 268 return nil, fmt.Errorf("error decoding index: %w", err) 269 } 270 271 return index, nil 272 } 273 274 func (c *nativeHelmChart) TestHelmOCI() (bool, error) { 275 start := time.Now() 276 277 tmpDir, err := os.MkdirTemp("", "helm") 278 if err != nil { 279 return false, fmt.Errorf("error creating temporary directory: %w", err) 280 } 281 defer func() { _ = os.RemoveAll(tmpDir) }() 282 283 helmCmd, err := NewCmdWithVersion(tmpDir, c.enableOci, c.proxy, c.noProxy) 284 if err != nil { 285 return false, fmt.Errorf("error creating Helm command: %w", err) 286 } 287 defer helmCmd.Close() 288 289 // Looks like there is no good way to test access to OCI repo if credentials are not provided 290 // just assume it is accessible 291 helmPassword, err := c.creds.GetPassword() 292 if err != nil { 293 return false, fmt.Errorf("failed to get password for helm registry: %w", err) 294 } 295 if c.creds.GetUsername() != "" && helmPassword != "" { 296 _, err = helmCmd.RegistryLogin(c.repoURL, c.creds) 297 if err != nil { 298 return false, fmt.Errorf("error logging into OCI registry: %w", err) 299 } 300 defer func() { 301 _, _ = helmCmd.RegistryLogout(c.repoURL, c.creds) 302 }() 303 304 log.WithFields(log.Fields{"seconds": time.Since(start).Seconds()}).Info("took to test helm oci repository") 305 } 306 return true, nil 307 } 308 309 func (c *nativeHelmChart) loadRepoIndex(maxIndexSize int64) ([]byte, error) { 310 indexURL, err := getIndexURL(c.repoURL) 311 if err != nil { 312 return nil, fmt.Errorf("error getting index URL: %w", err) 313 } 314 315 req, err := http.NewRequest(http.MethodGet, indexURL, http.NoBody) 316 if err != nil { 317 return nil, fmt.Errorf("error creating HTTP request: %w", err) 318 } 319 helmPassword, err := c.creds.GetPassword() 320 if err != nil { 321 return nil, fmt.Errorf("failed to get password for helm registry: %w", err) 322 } 323 if c.creds.GetUsername() != "" || helmPassword != "" { 324 // only basic supported 325 req.SetBasicAuth(c.creds.GetUsername(), helmPassword) 326 } 327 328 tlsConf, err := newTLSConfig(c.creds) 329 if err != nil { 330 return nil, fmt.Errorf("error creating TLS config: %w", err) 331 } 332 333 tr := &http.Transport{ 334 Proxy: proxy.GetCallback(c.proxy, c.noProxy), 335 TLSClientConfig: tlsConf, 336 DisableKeepAlives: true, 337 } 338 client := http.Client{Transport: tr} 339 resp, err := client.Do(req) 340 if err != nil { 341 return nil, fmt.Errorf("error making HTTP request: %w", err) 342 } 343 defer func() { _ = resp.Body.Close() }() 344 345 if resp.StatusCode != http.StatusOK { 346 return nil, errors.New("failed to get index: " + resp.Status) 347 } 348 return io.ReadAll(io.LimitReader(resp.Body, maxIndexSize)) 349 } 350 351 func newTLSConfig(creds Creds) (*tls.Config, error) { 352 tlsConfig := &tls.Config{InsecureSkipVerify: creds.GetInsecureSkipVerify()} 353 354 if creds.GetCAPath() != "" { 355 caData, err := os.ReadFile(creds.GetCAPath()) 356 if err != nil { 357 return nil, fmt.Errorf("error reading CA file %s: %w", creds.GetCAPath(), err) 358 } 359 caCertPool := x509.NewCertPool() 360 caCertPool.AppendCertsFromPEM(caData) 361 tlsConfig.RootCAs = caCertPool 362 } 363 364 // If a client cert & key is provided then configure TLS config accordingly. 365 if len(creds.GetCertData()) > 0 && len(creds.GetKeyData()) > 0 { 366 cert, err := tls.X509KeyPair(creds.GetCertData(), creds.GetKeyData()) 367 if err != nil { 368 return nil, fmt.Errorf("error creating X509 key pair: %w", err) 369 } 370 tlsConfig.Certificates = []tls.Certificate{cert} 371 } 372 //nolint:staticcheck 373 tlsConfig.BuildNameToCertificate() 374 375 return tlsConfig, nil 376 } 377 378 // Normalize a chart name for file system use, that is, if chart name is foo/bar/baz, returns the last component as chart name. 379 func normalizeChartName(chart string) string { 380 strings.Join(strings.Split(chart, "/"), "_") 381 _, nc := path.Split(chart) 382 // We do not want to return the empty string or something else related to filesystem access 383 // Instead, return original string 384 if nc == "" || nc == "." || nc == ".." { 385 return chart 386 } 387 return nc 388 } 389 390 func (c *nativeHelmChart) getCachedChartPath(chart string, version string) (string, error) { 391 keyData, err := json.Marshal(map[string]string{"url": c.repoURL, "chart": chart, "version": version}) 392 if err != nil { 393 return "", fmt.Errorf("error marshaling cache key data: %w", err) 394 } 395 return c.chartCachePaths.GetPath(string(keyData)) 396 } 397 398 // Ensures that given OCI registries URL does not have protocol 399 func IsHelmOciRepo(repoURL string) bool { 400 if repoURL == "" { 401 return false 402 } 403 parsed, err := url.Parse(repoURL) 404 // the URL parser treat hostname as either path or opaque if scheme is not specified, so hostname must be empty 405 return err == nil && parsed.Host == "" 406 } 407 408 func getIndexURL(rawURL string) (string, error) { 409 indexFile := "index.yaml" 410 repoURL, err := url.Parse(rawURL) 411 if err != nil { 412 return "", fmt.Errorf("error parsing repository URL: %w", err) 413 } 414 repoURL.Path = path.Join(repoURL.Path, indexFile) 415 repoURL.RawPath = path.Join(repoURL.RawPath, indexFile) 416 return repoURL.String(), nil 417 } 418 419 func (c *nativeHelmChart) GetTags(chart string, noCache bool) ([]string, error) { 420 if !c.enableOci { 421 return nil, ErrOCINotEnabled 422 } 423 424 tagsURL := strings.Replace(fmt.Sprintf("%s/%s", c.repoURL, chart), "https://", "", 1) 425 indexLock.Lock(tagsURL) 426 defer indexLock.Unlock(tagsURL) 427 428 var data []byte 429 if !noCache && c.indexCache != nil { 430 if err := c.indexCache.GetHelmIndex(tagsURL, &data); err != nil && !errors.Is(err, cache.ErrCacheMiss) { 431 log.Warnf("Failed to load index cache for repo: %s: %v", tagsURL, err) 432 } 433 } 434 435 type entriesStruct struct { 436 Tags []string 437 } 438 439 entries := &entriesStruct{} 440 if len(data) == 0 { 441 start := time.Now() 442 repo, err := remote.NewRepository(tagsURL) 443 if err != nil { 444 return nil, fmt.Errorf("failed to initialize repository: %w", err) 445 } 446 tlsConf, err := newTLSConfig(c.creds) 447 if err != nil { 448 return nil, fmt.Errorf("failed setup tlsConfig: %w", err) 449 } 450 client := &http.Client{Transport: &http.Transport{ 451 Proxy: proxy.GetCallback(c.proxy, c.noProxy), 452 TLSClientConfig: tlsConf, 453 DisableKeepAlives: true, 454 }} 455 456 repoHost, _, _ := strings.Cut(tagsURL, "/") 457 458 helmPassword, err := c.creds.GetPassword() 459 if err != nil { 460 return nil, fmt.Errorf("failed to get password for helm registry: %w", err) 461 } 462 credential := auth.StaticCredential(repoHost, auth.Credential{ 463 Username: c.creds.GetUsername(), 464 Password: helmPassword, 465 }) 466 467 // Try to fallback to the environment config, but we shouldn't error if the file is not set 468 if c.creds.GetUsername() == "" && helmPassword == "" { 469 store, _ := credentials.NewStoreFromDocker(credentials.StoreOptions{}) 470 if store != nil { 471 credential = credentials.Credential(store) 472 } 473 } 474 475 repo.Client = &auth.Client{ 476 Client: client, 477 Cache: nil, 478 Credential: credential, 479 } 480 481 ctx := context.Background() 482 err = repo.Tags(ctx, "", func(tagsResult []string) error { 483 for _, tag := range tagsResult { 484 // By convention: Change underscore (_) back to plus (+) to get valid SemVer 485 convertedTag := strings.ReplaceAll(tag, "_", "+") 486 entries.Tags = append(entries.Tags, convertedTag) 487 } 488 489 return nil 490 }) 491 if err != nil { 492 return nil, fmt.Errorf("failed to get tags: %w", err) 493 } 494 log.WithFields( 495 log.Fields{"seconds": time.Since(start).Seconds(), "chart": chart, "repo": c.repoURL}, 496 ).Info("took to get tags") 497 498 if c.indexCache != nil { 499 cacheData, err := json.Marshal(entries) 500 if err != nil { 501 return nil, fmt.Errorf("failed to encode tags: %w", err) 502 } 503 if err := c.indexCache.SetHelmIndex(tagsURL, cacheData); err != nil { 504 log.Warnf("Failed to store tags list cache for repo: %s: %v", tagsURL, err) 505 } 506 } 507 } else { 508 err := json.Unmarshal(data, entries) 509 if err != nil { 510 return nil, fmt.Errorf("failed to decode tags: %w", err) 511 } 512 } 513 514 return entries.Tags, nil 515 }