github.com/argoproj/argo-cd/v3@v3.2.1/util/oci/client.go (about) 1 package oci 2 3 import ( 4 "context" 5 "crypto/tls" 6 "crypto/x509" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io" 11 "io/fs" 12 "math" 13 "net/http" 14 "net/url" 15 "os" 16 "path" 17 "path/filepath" 18 "slices" 19 "strings" 20 "time" 21 22 securejoin "github.com/cyphar/filepath-securejoin" 23 imagev1 "github.com/opencontainers/image-spec/specs-go/v1" 24 "oras.land/oras-go/v2/content/oci" 25 26 "github.com/argoproj/argo-cd/v3/util/versions" 27 28 "github.com/argoproj/pkg/sync" 29 log "github.com/sirupsen/logrus" 30 31 "github.com/argoproj/argo-cd/v3/util/cache" 32 utilio "github.com/argoproj/argo-cd/v3/util/io" 33 "github.com/argoproj/argo-cd/v3/util/io/files" 34 "github.com/argoproj/argo-cd/v3/util/proxy" 35 36 "oras.land/oras-go/v2" 37 "oras.land/oras-go/v2/content/file" 38 "oras.land/oras-go/v2/registry/remote" 39 "oras.land/oras-go/v2/registry/remote/auth" 40 ) 41 42 var ( 43 globalLock = sync.NewKeyLock() 44 indexLock = sync.NewKeyLock() 45 ) 46 47 var _ Client = &nativeOCIClient{} 48 49 type tagsCache interface { 50 SetOCITags(repo string, indexData []byte) error 51 GetOCITags(repo string, indexData *[]byte) error 52 } 53 54 // Client is a generic OCI client interface that provides methods for interacting with an OCI (Open Container Initiative) registry. 55 type Client interface { 56 // ResolveRevision resolves a tag, digest, or semantic version constraint to a concrete digest. 57 // If noCache is true, the resolution bypasses the local tags cache and queries the remote registry. 58 // If the revision is already a digest, it is returned as-is. 59 ResolveRevision(ctx context.Context, revision string, noCache bool) (string, error) 60 61 // DigestMetadata retrieves an OCI manifest for a given digest. 62 DigestMetadata(ctx context.Context, digest string) (*imagev1.Manifest, error) 63 64 // CleanCache is invoked on a hard-refresh or when the manifest cache has expired. This removes the OCI image from 65 // the cached path, which is looked up by the specified revision. 66 CleanCache(revision string) error 67 68 // Extract retrieves and unpacks the contents of an OCI image identified by the specified revision. 69 // If successful, the extracted contents are extracted to a randomized tempdir. 70 Extract(ctx context.Context, revision string) (string, utilio.Closer, error) 71 72 // TestRepo verifies the connectivity and accessibility of the repository. 73 TestRepo(ctx context.Context) (bool, error) 74 75 // GetTags retrieves the list of tags for the repository. 76 GetTags(ctx context.Context, noCache bool) ([]string, error) 77 } 78 79 type Creds struct { 80 Username string 81 Password string 82 CAPath string 83 CertData []byte 84 KeyData []byte 85 InsecureSkipVerify bool 86 InsecureHTTPOnly bool 87 } 88 89 type ClientOpts func(c *nativeOCIClient) 90 91 func WithIndexCache(indexCache tagsCache) ClientOpts { 92 return func(c *nativeOCIClient) { 93 c.tagsCache = indexCache 94 } 95 } 96 97 func WithImagePaths(repoCachePaths utilio.TempPaths) ClientOpts { 98 return func(c *nativeOCIClient) { 99 c.repoCachePaths = repoCachePaths 100 } 101 } 102 103 func WithManifestMaxExtractedSize(manifestMaxExtractedSize int64) ClientOpts { 104 return func(c *nativeOCIClient) { 105 c.manifestMaxExtractedSize = manifestMaxExtractedSize 106 } 107 } 108 109 func WithDisableManifestMaxExtractedSize(disableManifestMaxExtractedSize bool) ClientOpts { 110 return func(c *nativeOCIClient) { 111 c.disableManifestMaxExtractedSize = disableManifestMaxExtractedSize 112 } 113 } 114 115 func NewClient(repoURL string, creds Creds, proxy, noProxy string, layerMediaTypes []string, opts ...ClientOpts) (Client, error) { 116 return NewClientWithLock(repoURL, creds, globalLock, proxy, noProxy, layerMediaTypes, opts...) 117 } 118 119 func NewClientWithLock(repoURL string, creds Creds, repoLock sync.KeyLock, proxyURL, noProxy string, layerMediaTypes []string, opts ...ClientOpts) (Client, error) { 120 ociRepo := strings.TrimPrefix(repoURL, "oci://") 121 repo, err := remote.NewRepository(ociRepo) 122 if err != nil { 123 return nil, fmt.Errorf("failed to initialize repository: %w", err) 124 } 125 126 repo.PlainHTTP = creds.InsecureHTTPOnly 127 128 var tlsConf *tls.Config 129 if !repo.PlainHTTP { 130 tlsConf, err = newTLSConfig(creds) 131 if err != nil { 132 return nil, fmt.Errorf("failed setup tlsConfig: %w", err) 133 } 134 } 135 136 client := &http.Client{ 137 Transport: &http.Transport{ 138 Proxy: proxy.GetCallback(proxyURL, noProxy), 139 TLSClientConfig: tlsConf, 140 DisableKeepAlives: true, 141 }, 142 /* 143 CheckRedirect: func(req *http.Request, via []*http.Request) error { 144 return errors.New("redirects are not allowed") 145 }, 146 */ 147 } 148 repo.Client = &auth.Client{ 149 Client: client, 150 Cache: nil, 151 Credential: auth.StaticCredential(repo.Reference.Registry, auth.Credential{ 152 Username: creds.Username, 153 Password: creds.Password, 154 }), 155 } 156 157 parsed, err := url.Parse(repoURL) 158 if err != nil { 159 return nil, fmt.Errorf("failed to parse oci repo url: %w", err) 160 } 161 162 reg, err := remote.NewRegistry(parsed.Host) 163 if err != nil { 164 return nil, fmt.Errorf("failed to setup registry config: %w", err) 165 } 166 reg.PlainHTTP = repo.PlainHTTP 167 reg.Client = repo.Client 168 return newClientWithLock(ociRepo, repoLock, repo, func(ctx context.Context, last string) ([]string, error) { 169 var t []string 170 171 err := repo.Tags(ctx, last, func(tags []string) error { 172 t = append(t, tags...) 173 return nil 174 }) 175 176 return t, err 177 }, reg.Ping, layerMediaTypes, opts...), nil 178 } 179 180 func newClientWithLock(repoURL string, repoLock sync.KeyLock, repo oras.ReadOnlyTarget, tagsFunc func(context.Context, string) ([]string, error), pingFunc func(ctx context.Context) error, layerMediaTypes []string, opts ...ClientOpts) Client { 181 c := &nativeOCIClient{ 182 repoURL: repoURL, 183 repoLock: repoLock, 184 repo: repo, 185 tagsFunc: tagsFunc, 186 pingFunc: pingFunc, 187 allowedMediaTypes: layerMediaTypes, 188 } 189 for i := range opts { 190 opts[i](c) 191 } 192 return c 193 } 194 195 // nativeOCIClient implements Client interface using oras-go 196 type nativeOCIClient struct { 197 repoURL string 198 repo oras.ReadOnlyTarget 199 tagsFunc func(context.Context, string) ([]string, error) 200 repoLock sync.KeyLock 201 tagsCache tagsCache 202 repoCachePaths utilio.TempPaths 203 allowedMediaTypes []string 204 manifestMaxExtractedSize int64 205 disableManifestMaxExtractedSize bool 206 pingFunc func(ctx context.Context) error 207 } 208 209 // TestRepo verifies that the remote OCI repo can be connected to. 210 func (c *nativeOCIClient) TestRepo(ctx context.Context) (bool, error) { 211 err := c.pingFunc(ctx) 212 return err == nil, err 213 } 214 215 func (c *nativeOCIClient) Extract(ctx context.Context, digest string) (string, utilio.Closer, error) { 216 cachedPath, err := c.getCachedPath(digest) 217 if err != nil { 218 return "", nil, fmt.Errorf("error getting oci path for digest %s: %w", digest, err) 219 } 220 221 c.repoLock.Lock(cachedPath) 222 defer c.repoLock.Unlock(cachedPath) 223 224 exists, err := fileExists(cachedPath) 225 if err != nil { 226 return "", nil, err 227 } 228 229 if !exists { 230 ociManifest, err := getOCIManifest(ctx, digest, c.repo) 231 if err != nil { 232 return "", nil, err 233 } 234 235 // Add a guard to defend against a ridiculous amount of layers. No idea what a good amount is, but normally we 236 // shouldn't expect more than 2-3 in most real world use cases. 237 if len(ociManifest.Layers) > 10 { 238 return "", nil, fmt.Errorf("expected no more than 10 oci layers, got %d", len(ociManifest.Layers)) 239 } 240 241 contentLayers := 0 242 243 // Strictly speaking we only allow for a single content layer. There are images which contains extra layers, such 244 // as provenance/attestation layers. Pending a better story to do this natively, we will skip such layers for now. 245 for _, layer := range ociManifest.Layers { 246 if isContentLayer(layer.MediaType) { 247 if !slices.Contains(c.allowedMediaTypes, layer.MediaType) { 248 return "", nil, fmt.Errorf("oci layer media type %s is not in the list of allowed media types", layer.MediaType) 249 } 250 251 contentLayers++ 252 } 253 } 254 255 if contentLayers != 1 { 256 return "", nil, fmt.Errorf("expected only a single oci content layer, got %d", contentLayers) 257 } 258 259 err = saveCompressedImageToPath(ctx, digest, c.repo, cachedPath) 260 if err != nil { 261 return "", nil, fmt.Errorf("could not save oci digest %s: %w", digest, err) 262 } 263 } 264 265 maxSize := c.manifestMaxExtractedSize 266 if c.disableManifestMaxExtractedSize { 267 maxSize = math.MaxInt64 268 } 269 270 manifestsDir, err := extractContentToManifestsDir(ctx, cachedPath, digest, maxSize) 271 if err != nil { 272 return manifestsDir, nil, fmt.Errorf("cannot extract contents of oci image with revision %s: %w", digest, err) 273 } 274 275 return manifestsDir, utilio.NewCloser(func() error { 276 return os.RemoveAll(manifestsDir) 277 }), nil 278 } 279 280 func (c *nativeOCIClient) getCachedPath(version string) (string, error) { 281 keyData, err := json.Marshal(map[string]string{"url": c.repoURL, "version": version}) 282 if err != nil { 283 return "", err 284 } 285 return c.repoCachePaths.GetPath(string(keyData)) 286 } 287 288 func (c *nativeOCIClient) CleanCache(revision string) error { 289 cachePath, err := c.getCachedPath(revision) 290 if err != nil { 291 return fmt.Errorf("error cleaning oci path for revision %s: %w", revision, err) 292 } 293 return os.RemoveAll(cachePath) 294 } 295 296 // DigestMetadata extracts the OCI manifest for a given revision and returns it to the caller. 297 func (c *nativeOCIClient) DigestMetadata(ctx context.Context, digest string) (*imagev1.Manifest, error) { 298 path, err := c.getCachedPath(digest) 299 if err != nil { 300 return nil, fmt.Errorf("error fetching oci metadata path for digest %s: %w", digest, err) 301 } 302 303 repo, err := oci.NewFromTar(ctx, path) 304 if err != nil { 305 return nil, fmt.Errorf("error extracting oci image for digest %s: %w", digest, err) 306 } 307 308 return getOCIManifest(ctx, digest, repo) 309 } 310 311 func (c *nativeOCIClient) ResolveRevision(ctx context.Context, revision string, noCache bool) (string, error) { 312 digest, err := c.resolveDigest(ctx, revision) // Lookup explicit revision 313 if err != nil { 314 // If the revision is not a semver constraint, just return the error 315 if !versions.IsConstraint(revision) { 316 return digest, err 317 } 318 319 tags, err := c.GetTags(ctx, noCache) 320 if err != nil { 321 return "", fmt.Errorf("error fetching tags: %w", err) 322 } 323 324 // Look to see if revision is a semver constraint 325 version, err := versions.MaxVersion(revision, tags) 326 if err != nil { 327 return "", fmt.Errorf("no version for constraints: %w", err) 328 } 329 // Look up the digest for the resolved version 330 return c.resolveDigest(ctx, version) 331 } 332 333 return digest, nil 334 } 335 336 func (c *nativeOCIClient) GetTags(ctx context.Context, noCache bool) ([]string, error) { 337 indexLock.Lock(c.repoURL) 338 defer indexLock.Unlock(c.repoURL) 339 340 var data []byte 341 if !noCache && c.tagsCache != nil { 342 if err := c.tagsCache.GetOCITags(c.repoURL, &data); err != nil && !errors.Is(err, cache.ErrCacheMiss) { 343 log.Warnf("Failed to load index cache for repo: %s: %s", c.repoLock, err) 344 } 345 } 346 347 var tags []string 348 if len(data) == 0 { 349 start := time.Now() 350 result, err := c.tagsFunc(ctx, "") 351 if err != nil { 352 return nil, fmt.Errorf("failed to get tags: %w", err) 353 } 354 355 for _, tag := range result { 356 // By convention: Change underscore (_) back to plus (+) to get valid SemVer 357 convertedTag := strings.ReplaceAll(tag, "_", "+") 358 tags = append(tags, convertedTag) 359 } 360 361 log.WithFields( 362 log.Fields{"seconds": time.Since(start).Seconds(), "repo": c.repoURL}, 363 ).Info("took to get tags") 364 365 if c.tagsCache != nil { 366 if err := c.tagsCache.SetOCITags(c.repoURL, data); err != nil { 367 log.Warnf("Failed to store tags list cache for repo: %s: %s", c.repoURL, err) 368 } 369 } 370 } else if err := json.Unmarshal(data, &tags); err != nil { 371 return nil, fmt.Errorf("failed to decode tags: %w", err) 372 } 373 374 return tags, nil 375 } 376 377 // resolveDigest resolves a digest from a tag. 378 func (c *nativeOCIClient) resolveDigest(ctx context.Context, revision string) (string, error) { 379 descriptor, err := c.repo.Resolve(ctx, revision) 380 if err != nil { 381 return "", fmt.Errorf("cannot get digest for revision %s: %w", revision, err) 382 } 383 384 return descriptor.Digest.String(), nil 385 } 386 387 func newTLSConfig(creds Creds) (*tls.Config, error) { 388 tlsConfig := &tls.Config{InsecureSkipVerify: creds.InsecureSkipVerify} 389 390 if creds.CAPath != "" { 391 caData, err := os.ReadFile(creds.CAPath) 392 if err != nil { 393 return nil, err 394 } 395 caCertPool := x509.NewCertPool() 396 caCertPool.AppendCertsFromPEM(caData) 397 tlsConfig.RootCAs = caCertPool 398 } 399 400 // If a client cert & key is provided then configure TLS config accordingly. 401 if len(creds.CertData) > 0 && len(creds.KeyData) > 0 { 402 cert, err := tls.X509KeyPair(creds.CertData, creds.KeyData) 403 if err != nil { 404 return nil, err 405 } 406 tlsConfig.Certificates = []tls.Certificate{cert} 407 } 408 //nolint:staticcheck 409 tlsConfig.BuildNameToCertificate() 410 411 return tlsConfig, nil 412 } 413 414 func fileExists(filePath string) (bool, error) { 415 if _, err := os.Stat(filePath); err != nil { 416 if os.IsNotExist(err) { 417 return false, nil 418 } 419 return false, err 420 } 421 return true, nil 422 } 423 424 // TODO: A content layer could in theory be something that is not a compressed file, e.g a single yaml file or like. 425 // While IMO the utility in the context of Argo CD is limited, I'd at least like to make it known here and add an extensibility 426 // point for it in case we decide to loosen the current requirements. 427 func isContentLayer(mediaType string) bool { 428 return isCompressedLayer(mediaType) 429 } 430 431 func isCompressedLayer(mediaType string) bool { 432 // TODO: Is zstd something which is used in the wild? For now let's stick to these suffixes 433 return strings.HasSuffix(mediaType, "tar+gzip") || strings.HasSuffix(mediaType, "tar") 434 } 435 436 func createTarFile(from, to string) error { 437 f, err := os.Create(to) 438 if err != nil { 439 return err 440 } 441 if _, err = files.Tar(from, nil, nil, f); err != nil { 442 _ = os.RemoveAll(to) 443 } 444 return f.Close() 445 } 446 447 // saveCompressedImageToPath downloads a remote OCI image on a given digest and stores it as a TAR file in cachedPath. 448 func saveCompressedImageToPath(ctx context.Context, digest string, repo oras.ReadOnlyTarget, cachedPath string) error { 449 tempDir, err := files.CreateTempDir(os.TempDir()) 450 if err != nil { 451 return err 452 } 453 defer os.RemoveAll(tempDir) 454 455 store, err := oci.New(tempDir) 456 if err != nil { 457 return err 458 } 459 460 // Copy remote repo at the given digest to the scratch dir. 461 if _, err = oras.Copy(ctx, repo, digest, store, digest, oras.DefaultCopyOptions); err != nil { 462 return err 463 } 464 465 // Remove redundant ingest folder; this is an artifact from the oras.Copy call above 466 err = os.RemoveAll(path.Join(tempDir, "ingest")) 467 if err != nil { 468 return err 469 } 470 471 // Save contents to tar file 472 return createTarFile(tempDir, cachedPath) 473 } 474 475 // extractContentToManifestsDir looks up a locally stored OCI image, and extracts the embedded compressed layer which contains 476 // K8s manifests to a temporary directory 477 func extractContentToManifestsDir(ctx context.Context, cachedPath, digest string, maxSize int64) (string, error) { 478 manifestsDir, err := files.CreateTempDir(os.TempDir()) 479 if err != nil { 480 return manifestsDir, err 481 } 482 483 ociReadOnlyStore, err := oci.NewFromTar(ctx, cachedPath) 484 if err != nil { 485 return manifestsDir, err 486 } 487 488 tempDir, err := files.CreateTempDir(os.TempDir()) 489 if err != nil { 490 return manifestsDir, err 491 } 492 defer os.RemoveAll(tempDir) 493 494 fs, err := newCompressedLayerFileStore(manifestsDir, tempDir, maxSize) 495 if err != nil { 496 return manifestsDir, err 497 } 498 defer fs.Close() 499 500 // copies the whole artifact to the tempdir, here compressedLayerFileStore.Push will be called 501 _, err = oras.Copy(ctx, ociReadOnlyStore, digest, fs, digest, oras.DefaultCopyOptions) 502 return manifestsDir, err 503 } 504 505 type compressedLayerExtracterStore struct { 506 *file.Store 507 dest string 508 maxSize int64 509 } 510 511 func newCompressedLayerFileStore(dest, tempDir string, maxSize int64) (*compressedLayerExtracterStore, error) { 512 f, err := file.New(tempDir) 513 if err != nil { 514 return nil, err 515 } 516 517 return &compressedLayerExtracterStore{f, dest, maxSize}, nil 518 } 519 520 func isHelmOCI(mediaType string) bool { 521 return mediaType == "application/vnd.cncf.helm.chart.content.v1.tar+gzip" 522 } 523 524 // Push looks in all the layers of an OCI image. Once it finds a layer that is compressed, it extracts the layer to a tempDir 525 // and then renames the temp dir to the directory where the repo-server expects to find k8s manifests. 526 func (s *compressedLayerExtracterStore) Push(ctx context.Context, desc imagev1.Descriptor, content io.Reader) error { 527 if isContentLayer(desc.MediaType) { 528 srcDir, err := files.CreateTempDir(os.TempDir()) 529 if err != nil { 530 return err 531 } 532 defer os.RemoveAll(srcDir) 533 534 if strings.HasSuffix(desc.MediaType, "tar+gzip") { 535 err = files.Untgz(srcDir, content, s.maxSize, false) 536 } else { 537 err = files.Untar(srcDir, content, s.maxSize, false) 538 } 539 540 if err != nil { 541 return fmt.Errorf("could not decompress layer: %w", err) 542 } 543 544 if isHelmOCI(desc.MediaType) { 545 infos, err := os.ReadDir(srcDir) 546 if err != nil { 547 return err 548 } 549 550 // For a Helm chart we expect a single directory 551 if len(infos) != 1 || !infos[0].IsDir() { 552 return fmt.Errorf("expected 1 directory, found %v", len(infos)) 553 } 554 555 // For Helm charts, we will move the contents of the unpacked directory to the root of its final destination 556 srcDir, err = securejoin.SecureJoin(srcDir, infos[0].Name()) 557 if err != nil { 558 return err 559 } 560 } 561 562 return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, _ error) error { 563 if path != srcDir { 564 // Calculate the relative path from srcDir 565 relPath, err := filepath.Rel(srcDir, path) 566 if err != nil { 567 return err 568 } 569 570 dstPath, err := securejoin.SecureJoin(s.dest, relPath) 571 if err != nil { 572 return err 573 } 574 575 // Move the file by renaming it 576 if d.IsDir() { 577 info, err := d.Info() 578 if err != nil { 579 return err 580 } 581 582 return os.MkdirAll(dstPath, info.Mode()) 583 } 584 585 return os.Rename(path, dstPath) 586 } 587 588 return nil 589 }) 590 } 591 592 return s.Store.Push(ctx, desc, content) 593 } 594 595 func getOCIManifest(ctx context.Context, digest string, repo oras.ReadOnlyTarget) (*imagev1.Manifest, error) { 596 desc, err := repo.Resolve(ctx, digest) 597 if err != nil { 598 return nil, fmt.Errorf("error resolving oci repo from digest, %w", err) 599 } 600 601 rc, err := repo.Fetch(ctx, desc) 602 if err != nil { 603 return nil, fmt.Errorf("error fetching oci manifest for digest %s: %w", digest, err) 604 } 605 606 manifest := imagev1.Manifest{} 607 decoder := json.NewDecoder(rc) 608 if err = decoder.Decode(&manifest); err != nil { 609 return nil, fmt.Errorf("error decoding oci manifest for digest %s: %w", digest, err) 610 } 611 612 return &manifest, nil 613 }