zotregistry.dev/zot@v1.4.4-0.20240314164342-eec277e14d20/pkg/cli/client/client.go (about) 1 //go:build search 2 // +build search 3 4 package client 5 6 import ( 7 "bytes" 8 "context" 9 "encoding/json" 10 "fmt" 11 "io" 12 "net/http" 13 "net/url" 14 "strconv" 15 "strings" 16 "sync" 17 "time" 18 19 ispec "github.com/opencontainers/image-spec/specs-go/v1" 20 "github.com/sigstore/cosign/v2/pkg/oci/remote" 21 22 zerr "zotregistry.dev/zot/errors" 23 "zotregistry.dev/zot/pkg/common" 24 ) 25 26 var ( 27 httpClientsMap = make(map[string]*http.Client) //nolint: gochecknoglobals 28 httpClientLock sync.Mutex //nolint: gochecknoglobals 29 ) 30 31 func makeGETRequest(ctx context.Context, url, username, password string, 32 verifyTLS bool, debug bool, resultsPtr interface{}, configWriter io.Writer, 33 ) (http.Header, error) { 34 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 35 if err != nil { 36 return nil, err 37 } 38 39 req.SetBasicAuth(username, password) 40 41 return doHTTPRequest(req, verifyTLS, debug, resultsPtr, configWriter) 42 } 43 44 func makeHEADRequest(ctx context.Context, url, username, password string, verifyTLS bool, 45 debug bool, 46 ) (http.Header, error) { 47 req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) 48 if err != nil { 49 return nil, err 50 } 51 52 req.SetBasicAuth(username, password) 53 54 return doHTTPRequest(req, verifyTLS, debug, nil, io.Discard) 55 } 56 57 func makeGraphQLRequest(ctx context.Context, url, query, username, 58 password string, verifyTLS bool, debug bool, resultsPtr interface{}, configWriter io.Writer, 59 ) error { 60 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, bytes.NewBufferString(query)) 61 if err != nil { 62 return err 63 } 64 65 q := req.URL.Query() 66 q.Add("query", query) 67 68 req.URL.RawQuery = q.Encode() 69 70 req.SetBasicAuth(username, password) 71 req.Header.Add("Content-Type", "application/json") 72 73 _, err = doHTTPRequest(req, verifyTLS, debug, resultsPtr, configWriter) 74 if err != nil { 75 return err 76 } 77 78 return nil 79 } 80 81 func doHTTPRequest(req *http.Request, verifyTLS bool, debug bool, 82 resultsPtr interface{}, configWriter io.Writer, 83 ) (http.Header, error) { 84 var httpClient *http.Client 85 86 var err error 87 88 host := req.Host 89 90 httpClientLock.Lock() 91 92 if httpClientsMap[host] == nil { 93 httpClient, err = common.CreateHTTPClient(verifyTLS, host, "") 94 if err != nil { 95 return nil, err 96 } 97 98 httpClientsMap[host] = httpClient 99 } else { 100 httpClient = httpClientsMap[host] 101 } 102 103 httpClientLock.Unlock() 104 105 if debug { 106 fmt.Fprintln(configWriter, "[debug] ", req.Method, " ", req.URL, "[request header] ", req.Header) 107 } 108 109 resp, err := httpClient.Do(req) 110 if err != nil { 111 return nil, err 112 } 113 114 if debug { 115 fmt.Fprintln(configWriter, "[debug] ", req.Method, req.URL, "[status] ", 116 resp.StatusCode, " ", "[response header] ", resp.Header) 117 } 118 119 defer resp.Body.Close() 120 121 if resp.StatusCode != http.StatusOK { 122 var err error 123 124 switch resp.StatusCode { 125 case http.StatusNotFound: 126 err = zerr.ErrURLNotFound 127 case http.StatusUnauthorized: 128 err = zerr.ErrUnauthorizedAccess 129 default: 130 err = zerr.ErrBadHTTPStatusCode 131 } 132 133 bodyBytes, _ := io.ReadAll(resp.Body) 134 135 return nil, fmt.Errorf("%w: Expected: %d, Got: %d, Body: '%s'", err, http.StatusOK, 136 resp.StatusCode, string(bodyBytes)) 137 } 138 139 if resultsPtr == nil { 140 return resp.Header, nil 141 } 142 143 if err := json.NewDecoder(resp.Body).Decode(resultsPtr); err != nil { 144 return nil, err 145 } 146 147 return resp.Header, nil 148 } 149 150 func validateURL(str string) error { 151 parsedURL, err := url.Parse(str) 152 if err != nil { 153 if strings.Contains(err.Error(), "first path segment in URL cannot contain colon") { 154 return fmt.Errorf("%w: scheme not provided (ex: https://)", zerr.ErrInvalidURL) 155 } 156 157 return err 158 } 159 160 if parsedURL.Scheme == "" || parsedURL.Host == "" { 161 return fmt.Errorf("%w: scheme not provided (ex: https://)", zerr.ErrInvalidURL) 162 } 163 164 return nil 165 } 166 167 type requestsPool struct { 168 jobs chan *httpJob 169 done chan struct{} 170 wtgrp *sync.WaitGroup 171 outputCh chan stringResult 172 } 173 174 type httpJob struct { 175 url string 176 username string 177 password string 178 imageName string 179 tagName string 180 config SearchConfig 181 } 182 183 const rateLimiterBuffer = 5000 184 185 func newSmoothRateLimiter(wtgrp *sync.WaitGroup, opch chan stringResult) *requestsPool { 186 ch := make(chan *httpJob, rateLimiterBuffer) 187 188 return &requestsPool{ 189 jobs: ch, 190 done: make(chan struct{}), 191 wtgrp: wtgrp, 192 outputCh: opch, 193 } 194 } 195 196 // block every "rateLimit" time duration. 197 const rateLimit = 100 * time.Millisecond 198 199 func (p *requestsPool) startRateLimiter(ctx context.Context) { 200 p.wtgrp.Done() 201 202 throttle := time.NewTicker(rateLimit).C 203 204 for { 205 select { 206 case job := <-p.jobs: 207 go p.doJob(ctx, job) 208 case <-p.done: 209 return 210 } 211 <-throttle 212 } 213 } 214 215 func (p *requestsPool) doJob(ctx context.Context, job *httpJob) { 216 defer p.wtgrp.Done() 217 218 // Check manifest media type 219 header, err := makeHEADRequest(ctx, job.url, job.username, job.password, job.config.VerifyTLS, 220 job.config.Debug) 221 if err != nil { 222 if common.IsContextDone(ctx) { 223 return 224 } 225 p.outputCh <- stringResult{"", err} 226 } 227 228 verbose := job.config.Verbose 229 230 switch header.Get("Content-Type") { 231 case ispec.MediaTypeImageManifest: 232 image, err := fetchImageManifestStruct(ctx, job) 233 if err != nil { 234 if common.IsContextDone(ctx) { 235 return 236 } 237 p.outputCh <- stringResult{"", err} 238 239 return 240 } 241 platformStr := getPlatformStr(image.Manifests[0].Platform) 242 243 str, err := image.string(job.config.OutputFormat, len(job.imageName), len(job.tagName), len(platformStr), verbose) 244 if err != nil { 245 if common.IsContextDone(ctx) { 246 return 247 } 248 p.outputCh <- stringResult{"", err} 249 250 return 251 } 252 253 if common.IsContextDone(ctx) { 254 return 255 } 256 257 p.outputCh <- stringResult{str, nil} 258 case ispec.MediaTypeImageIndex: 259 image, err := fetchImageIndexStruct(ctx, job) 260 if err != nil { 261 if common.IsContextDone(ctx) { 262 return 263 } 264 p.outputCh <- stringResult{"", err} 265 266 return 267 } 268 269 platformStr := getPlatformStr(image.Manifests[0].Platform) 270 271 str, err := image.string(job.config.OutputFormat, len(job.imageName), len(job.tagName), len(platformStr), verbose) 272 if err != nil { 273 if common.IsContextDone(ctx) { 274 return 275 } 276 p.outputCh <- stringResult{"", err} 277 278 return 279 } 280 281 if common.IsContextDone(ctx) { 282 return 283 } 284 285 p.outputCh <- stringResult{str, nil} 286 default: 287 return 288 } 289 } 290 291 func fetchImageIndexStruct(ctx context.Context, job *httpJob) (*imageStruct, error) { 292 var indexContent ispec.Index 293 294 header, err := makeGETRequest(ctx, job.url, job.username, job.password, 295 job.config.VerifyTLS, job.config.Debug, &indexContent, job.config.ResultWriter) 296 if err != nil { 297 if common.IsContextDone(ctx) { 298 return nil, context.Canceled 299 } 300 301 return nil, err 302 } 303 304 indexDigest := header.Get("docker-content-digest") 305 306 indexSize, err := strconv.ParseInt(header.Get("Content-Length"), 10, 64) 307 if err != nil { 308 return nil, err 309 } 310 311 imageSize := indexSize 312 313 manifestList := make([]common.ManifestSummary, 0, len(indexContent.Manifests)) 314 315 for _, manifestDescriptor := range indexContent.Manifests { 316 manifest, err := fetchManifestStruct(ctx, job.imageName, manifestDescriptor.Digest.String(), 317 job.config, job.username, job.password) 318 if err != nil { 319 return nil, err 320 } 321 322 imageSize += int64(atoiWithDefault(manifest.Size, 0)) 323 324 if manifestDescriptor.Platform != nil { 325 manifest.Platform = common.Platform{ 326 Os: manifestDescriptor.Platform.OS, 327 Arch: manifestDescriptor.Platform.Architecture, 328 Variant: manifestDescriptor.Platform.Variant, 329 } 330 } 331 332 manifestList = append(manifestList, manifest) 333 } 334 335 isIndexSigned := isCosignSigned(ctx, job.imageName, indexDigest, job.config, job.username, job.password) || 336 isNotationSigned(ctx, job.imageName, indexDigest, job.config, job.username, job.password) 337 338 return &imageStruct{ 339 RepoName: job.imageName, 340 Tag: job.tagName, 341 Digest: indexDigest, 342 MediaType: ispec.MediaTypeImageIndex, 343 Manifests: manifestList, 344 Size: strconv.FormatInt(imageSize, 10), 345 IsSigned: isIndexSigned, 346 }, nil 347 } 348 349 func atoiWithDefault(size string, defaultVal int) int { 350 val, err := strconv.Atoi(size) 351 if err != nil { 352 return defaultVal 353 } 354 355 return val 356 } 357 358 func fetchImageManifestStruct(ctx context.Context, job *httpJob) (*imageStruct, error) { 359 manifest, err := fetchManifestStruct(ctx, job.imageName, job.tagName, job.config, job.username, job.password) 360 if err != nil { 361 return nil, err 362 } 363 364 return &imageStruct{ 365 RepoName: job.imageName, 366 Tag: job.tagName, 367 Digest: manifest.Digest, 368 MediaType: ispec.MediaTypeImageManifest, 369 Manifests: []common.ManifestSummary{ 370 manifest, 371 }, 372 Size: manifest.Size, 373 IsSigned: manifest.IsSigned, 374 }, nil 375 } 376 377 func fetchManifestStruct(ctx context.Context, repo, manifestReference string, searchConf SearchConfig, 378 username, password string, 379 ) (common.ManifestSummary, error) { 380 manifestResp := ispec.Manifest{} 381 382 URL := fmt.Sprintf("%s/v2/%s/manifests/%s", 383 searchConf.ServURL, repo, manifestReference) 384 385 header, err := makeGETRequest(ctx, URL, username, password, 386 searchConf.VerifyTLS, searchConf.Debug, &manifestResp, searchConf.ResultWriter) 387 if err != nil { 388 if common.IsContextDone(ctx) { 389 return common.ManifestSummary{}, context.Canceled 390 } 391 392 return common.ManifestSummary{}, err 393 } 394 395 manifestDigest := header.Get("docker-content-digest") 396 configDigest := manifestResp.Config.Digest.String() 397 398 configContent, err := fetchConfig(ctx, repo, configDigest, searchConf, username, password) 399 if err != nil { 400 if common.IsContextDone(ctx) { 401 return common.ManifestSummary{}, context.Canceled 402 } 403 404 return common.ManifestSummary{}, err 405 } 406 407 opSys := "" 408 arch := "" 409 variant := "" 410 411 if manifestResp.Config.Platform != nil { 412 opSys = manifestResp.Config.Platform.OS 413 arch = manifestResp.Config.Platform.Architecture 414 variant = manifestResp.Config.Platform.Variant 415 } 416 417 if opSys == "" { 418 opSys = configContent.OS 419 } 420 421 if arch == "" { 422 arch = configContent.Architecture 423 } 424 425 if variant == "" { 426 variant = configContent.Variant 427 } 428 429 manifestSize, err := strconv.ParseInt(header.Get("Content-Length"), 10, 64) 430 if err != nil { 431 return common.ManifestSummary{}, err 432 } 433 434 var imageSize int64 435 436 imageSize += manifestResp.Config.Size 437 imageSize += manifestSize 438 439 layers := []common.LayerSummary{} 440 441 for _, entry := range manifestResp.Layers { 442 imageSize += entry.Size 443 444 layers = append( 445 layers, 446 common.LayerSummary{ 447 Size: fmt.Sprintf("%v", entry.Size), 448 Digest: entry.Digest.String(), 449 }, 450 ) 451 } 452 453 isSigned := isCosignSigned(ctx, repo, manifestDigest, searchConf, username, password) || 454 isNotationSigned(ctx, repo, manifestDigest, searchConf, username, password) 455 456 return common.ManifestSummary{ 457 ConfigDigest: configDigest, 458 Digest: manifestDigest, 459 Layers: layers, 460 Platform: common.Platform{Os: opSys, Arch: arch, Variant: variant}, 461 Size: strconv.FormatInt(imageSize, 10), 462 IsSigned: isSigned, 463 }, nil 464 } 465 466 func fetchConfig(ctx context.Context, repo, configDigest string, searchConf SearchConfig, 467 username, password string, 468 ) (ispec.Image, error) { 469 configContent := ispec.Image{} 470 471 URL := fmt.Sprintf("%s/v2/%s/blobs/%s", 472 searchConf.ServURL, repo, configDigest) 473 474 _, err := makeGETRequest(ctx, URL, username, password, 475 searchConf.VerifyTLS, searchConf.Debug, &configContent, searchConf.ResultWriter) 476 if err != nil { 477 if common.IsContextDone(ctx) { 478 return ispec.Image{}, context.Canceled 479 } 480 481 return ispec.Image{}, err 482 } 483 484 return configContent, nil 485 } 486 487 func isNotationSigned(ctx context.Context, repo, digestStr string, searchConf SearchConfig, 488 username, password string, 489 ) bool { 490 var referrers ispec.Index 491 492 URL := fmt.Sprintf("%s/v2/%s/referrers/%s?artifactType=%s", 493 searchConf.ServURL, repo, digestStr, common.ArtifactTypeNotation) 494 495 _, err := makeGETRequest(ctx, URL, username, password, 496 searchConf.VerifyTLS, searchConf.Debug, &referrers, searchConf.ResultWriter) 497 if err != nil { 498 return false 499 } 500 501 if len(referrers.Manifests) > 0 { 502 return true 503 } 504 505 return false 506 } 507 508 func isCosignSigned(ctx context.Context, repo, digestStr string, searchConf SearchConfig, 509 username, password string, 510 ) bool { 511 var result interface{} 512 cosignTag := strings.Replace(digestStr, ":", "-", 1) + "." + remote.SignatureTagSuffix 513 514 URL := fmt.Sprintf("%s/v2/%s/manifests/%s", searchConf.ServURL, repo, cosignTag) 515 516 _, err := makeGETRequest(ctx, URL, username, password, searchConf.VerifyTLS, 517 searchConf.Debug, &result, searchConf.ResultWriter) 518 519 if err == nil { 520 return true 521 } 522 523 var referrers ispec.Index 524 525 artifactType := url.QueryEscape(common.ArtifactTypeCosign) 526 URL = fmt.Sprintf("%s/v2/%s/referrers/%s?artifactType=%s", 527 searchConf.ServURL, repo, digestStr, artifactType) 528 529 _, err = makeGETRequest(ctx, URL, username, password, searchConf.VerifyTLS, 530 searchConf.Debug, &referrers, searchConf.ResultWriter) 531 if err != nil { 532 return false 533 } 534 535 if len(referrers.Manifests) == 0 { 536 return false 537 } 538 539 return true 540 } 541 542 func (p *requestsPool) submitJob(job *httpJob) { 543 p.jobs <- job 544 }