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