zotregistry.dev/zot@v1.4.4-0.20240314164342-eec277e14d20/pkg/cli/client/utils.go (about) 1 //go:build search 2 // +build search 3 4 package client 5 6 import ( 7 "context" 8 "fmt" 9 "io" 10 "os" 11 "path" 12 "strings" 13 "sync" 14 "time" 15 16 "github.com/briandowns/spinner" 17 "github.com/spf13/cobra" 18 19 zerr "zotregistry.dev/zot/errors" 20 "zotregistry.dev/zot/pkg/api/constants" 21 ) 22 23 const ( 24 sizeColumn = "SIZE" 25 ) 26 27 func ref[T any](input T) *T { 28 ref := input 29 30 return &ref 31 } 32 33 func fetchImageDigest(repo, ref, username, password string, config SearchConfig) (string, error) { 34 url, err := combineServerAndEndpointURL(config.ServURL, fmt.Sprintf("/v2/%s/manifests/%s", repo, ref)) 35 if err != nil { 36 return "", err 37 } 38 39 res, err := makeHEADRequest(context.Background(), url, username, password, config.VerifyTLS, false) 40 41 digestStr := res.Get(constants.DistContentDigestKey) 42 43 return digestStr, err 44 } 45 46 func collectResults(config SearchConfig, wg *sync.WaitGroup, imageErr chan stringResult, 47 cancel context.CancelFunc, printHeader printHeader, errCh chan error, 48 ) { 49 var foundResult bool 50 51 defer wg.Done() 52 config.Spinner.startSpinner() 53 54 for { 55 select { 56 case result, ok := <-imageErr: 57 config.Spinner.stopSpinner() 58 59 if !ok { 60 cancel() 61 62 return 63 } 64 65 if result.Err != nil { 66 cancel() 67 errCh <- result.Err 68 69 return 70 } 71 72 if !foundResult && (config.OutputFormat == defaultOutputFormat || config.OutputFormat == "") { 73 var builder strings.Builder 74 75 printHeader(&builder, config.Verbose, 0, 0, 0) 76 fmt.Fprint(config.ResultWriter, builder.String()) 77 } 78 79 foundResult = true 80 81 fmt.Fprint(config.ResultWriter, result.StrValue) 82 case <-time.After(waitTimeout): 83 config.Spinner.stopSpinner() 84 cancel() 85 86 errCh <- zerr.ErrCLITimeout 87 88 return 89 } 90 } 91 } 92 93 func getUsernameAndPassword(user string) (string, string) { 94 if strings.Contains(user, ":") { 95 split := strings.Split(user, ":") 96 97 return split[0], split[1] 98 } 99 100 return "", "" 101 } 102 103 type spinnerState struct { 104 spinner *spinner.Spinner 105 enabled bool 106 } 107 108 func (spinner *spinnerState) startSpinner() { 109 if spinner.enabled { 110 spinner.spinner.Start() 111 } 112 } 113 114 func (spinner *spinnerState) stopSpinner() { 115 if spinner.enabled && spinner.spinner.Active() { 116 spinner.spinner.Stop() 117 } 118 } 119 120 const ( 121 waitTimeout = 5 * time.Minute 122 ) 123 124 type stringResult struct { 125 StrValue string 126 Err error 127 } 128 129 type printHeader func(writer io.Writer, verbose bool, maxImageNameLen, maxTagLen, maxPlatformLen int) 130 131 func printImageTableHeader(writer io.Writer, verbose bool, maxImageNameLen, maxTagLen, maxPlatformLen int) { 132 table := getImageTableWriter(writer) 133 134 table.SetColMinWidth(colImageNameIndex, imageNameWidth) 135 table.SetColMinWidth(colTagIndex, tagWidth) 136 table.SetColMinWidth(colPlatformIndex, platformWidth) 137 table.SetColMinWidth(colDigestIndex, digestWidth) 138 table.SetColMinWidth(colSizeIndex, sizeWidth) 139 table.SetColMinWidth(colIsSignedIndex, isSignedWidth) 140 141 if verbose { 142 table.SetColMinWidth(colConfigIndex, configWidth) 143 table.SetColMinWidth(colLayersIndex, layersWidth) 144 } 145 146 row := make([]string, 8) //nolint:gomnd 147 148 // adding spaces so that repository and tag columns are aligned 149 // in case the name/tag are fully shown and too long 150 var offset string 151 if maxImageNameLen > len("REPOSITORY") { 152 offset = strings.Repeat(" ", maxImageNameLen-len("REPOSITORY")) 153 row[colImageNameIndex] = "REPOSITORY" + offset 154 } else { 155 row[colImageNameIndex] = "REPOSITORY" 156 } 157 158 if maxTagLen > len("TAG") { 159 offset = strings.Repeat(" ", maxTagLen-len("TAG")) 160 row[colTagIndex] = "TAG" + offset 161 } else { 162 row[colTagIndex] = "TAG" 163 } 164 165 if maxPlatformLen > len("OS/ARCH") { 166 offset = strings.Repeat(" ", maxPlatformLen-len("OS/ARCH")) 167 row[colPlatformIndex] = "OS/ARCH" + offset 168 } else { 169 row[colPlatformIndex] = "OS/ARCH" 170 } 171 172 row[colDigestIndex] = "DIGEST" 173 row[colSizeIndex] = sizeColumn 174 row[colIsSignedIndex] = "SIGNED" 175 176 if verbose { 177 row[colConfigIndex] = "CONFIG" 178 row[colLayersIndex] = "LAYERS" 179 } 180 181 table.Append(row) 182 table.Render() 183 } 184 185 func printCVETableHeader(writer io.Writer) { 186 table := getCVETableWriter(writer) 187 row := make([]string, 3) //nolint:gomnd 188 row[colCVEIDIndex] = "ID" 189 row[colCVESeverityIndex] = "SEVERITY" 190 row[colCVETitleIndex] = "TITLE" 191 192 table.Append(row) 193 table.Render() 194 } 195 196 func printReferrersTableHeader(config SearchConfig, writer io.Writer, maxArtifactTypeLen int) { 197 if config.OutputFormat != "" && config.OutputFormat != defaultOutputFormat { 198 return 199 } 200 201 table := getReferrersTableWriter(writer) 202 203 table.SetColMinWidth(refArtifactTypeIndex, maxArtifactTypeLen) 204 table.SetColMinWidth(refDigestIndex, digestWidth) 205 table.SetColMinWidth(refSizeIndex, sizeWidth) 206 207 row := make([]string, refRowWidth) 208 209 // adding spaces so that repository and tag columns are aligned 210 // in case the name/tag are fully shown and too long 211 var offset string 212 213 if maxArtifactTypeLen > len("ARTIFACT TYPE") { 214 offset = strings.Repeat(" ", maxArtifactTypeLen-len("ARTIFACT TYPE")) 215 row[refArtifactTypeIndex] = "ARTIFACT TYPE" + offset 216 } else { 217 row[refArtifactTypeIndex] = "ARTIFACT TYPE" 218 } 219 220 row[refDigestIndex] = "DIGEST" 221 row[refSizeIndex] = sizeColumn 222 223 table.Append(row) 224 table.Render() 225 } 226 227 func printRepoTableHeader(writer io.Writer, repoMaxLen, maxTimeLen int, verbose bool) { 228 table := getRepoTableWriter(writer) 229 230 table.SetColMinWidth(repoNameIndex, repoMaxLen) 231 table.SetColMinWidth(repoSizeIndex, sizeWidth) 232 table.SetColMinWidth(repoLastUpdatedIndex, maxTimeLen) 233 table.SetColMinWidth(repoDownloadsIndex, sizeWidth) 234 table.SetColMinWidth(repoStarsIndex, sizeWidth) 235 236 if verbose { 237 table.SetColMinWidth(repoPlatformsIndex, platformWidth) 238 } 239 240 row := make([]string, repoRowWidth) 241 242 // adding spaces so that repository and tag columns are aligned 243 // in case the name/tag are fully shown and too long 244 var offset string 245 246 if repoMaxLen > len("NAME") { 247 offset = strings.Repeat(" ", repoMaxLen-len("NAME")) 248 row[repoNameIndex] = "NAME" + offset 249 } else { 250 row[repoNameIndex] = "NAME" 251 } 252 253 if repoMaxLen > len("LAST UPDATED") { 254 offset = strings.Repeat(" ", repoMaxLen-len("LAST UPDATED")) 255 row[repoLastUpdatedIndex] = "LAST UPDATED" + offset 256 } else { 257 row[repoLastUpdatedIndex] = "LAST UPDATED" 258 } 259 260 row[repoSizeIndex] = sizeColumn 261 row[repoDownloadsIndex] = "DOWNLOADS" 262 row[repoStarsIndex] = "STARS" 263 264 if verbose { 265 row[repoPlatformsIndex] = "PLATFORMS" 266 } 267 268 table.Append(row) 269 table.Render() 270 } 271 272 func printReferrersResult(config SearchConfig, referrersList referrersResult, maxArtifactTypeLen int) error { 273 out, err := referrersList.string(config.OutputFormat, maxArtifactTypeLen) 274 if err != nil { 275 return err 276 } 277 278 fmt.Fprint(config.ResultWriter, out) 279 280 return nil 281 } 282 283 func printImageResult(config SearchConfig, imageList []imageStruct) error { 284 var builder strings.Builder 285 maxImgNameLen := 0 286 maxTagLen := 0 287 maxPlatformLen := 0 288 289 if len(imageList) > 0 { 290 for i := range imageList { 291 if maxImgNameLen < len(imageList[i].RepoName) { 292 maxImgNameLen = len(imageList[i].RepoName) 293 } 294 295 if maxTagLen < len(imageList[i].Tag) { 296 maxTagLen = len(imageList[i].Tag) 297 } 298 299 for j := range imageList[i].Manifests { 300 platform := imageList[i].Manifests[j].Platform.Os + "/" + imageList[i].Manifests[j].Platform.Arch 301 302 if maxPlatformLen < len(platform) { 303 maxPlatformLen = len(platform) 304 } 305 } 306 } 307 308 if config.OutputFormat == defaultOutputFormat || config.OutputFormat == "" { 309 printImageTableHeader(&builder, config.Verbose, maxImgNameLen, maxTagLen, maxPlatformLen) 310 } 311 312 fmt.Fprint(config.ResultWriter, builder.String()) 313 } 314 315 for i := range imageList { 316 img := imageList[i] 317 verbose := config.Verbose 318 319 out, err := img.string(config.OutputFormat, maxImgNameLen, maxTagLen, maxPlatformLen, verbose) 320 if err != nil { 321 return err 322 } 323 324 fmt.Fprint(config.ResultWriter, out) 325 } 326 327 return nil 328 } 329 330 func printRepoResults(config SearchConfig, repoList []repoStruct) error { 331 maxRepoNameLen := 0 332 maxTimeLen := 0 333 334 for _, repo := range repoList { 335 if maxRepoNameLen < len(repo.Name) { 336 maxRepoNameLen = len(repo.Name) 337 } 338 339 if maxTimeLen < len(repo.LastUpdated.String()) { 340 maxTimeLen = len(repo.LastUpdated.String()) 341 } 342 } 343 344 if len(repoList) > 0 && (config.OutputFormat == defaultOutputFormat || config.OutputFormat == "") { 345 printRepoTableHeader(config.ResultWriter, maxRepoNameLen, maxTimeLen, config.Verbose) 346 } 347 348 for _, repo := range repoList { 349 out, err := repo.string(config.OutputFormat, maxRepoNameLen, maxTimeLen, config.Verbose) 350 if err != nil { 351 return err 352 } 353 354 fmt.Fprint(config.ResultWriter, out) 355 } 356 357 return nil 358 } 359 360 func GetSearchConfigFromFlags(cmd *cobra.Command, searchService SearchService) (SearchConfig, error) { 361 serverURL, err := GetServerURLFromFlags(cmd) 362 if err != nil { 363 return SearchConfig{}, err 364 } 365 366 isSpinner, verifyTLS, err := GetCliConfigOptions(cmd) 367 if err != nil { 368 return SearchConfig{}, err 369 } 370 371 flags := cmd.Flags() 372 user := defaultIfError(flags.GetString(UserFlag)) 373 fixed := defaultIfError(flags.GetBool(FixedFlag)) 374 debug := defaultIfError(flags.GetBool(DebugFlag)) 375 verbose := defaultIfError(flags.GetBool(VerboseFlag)) 376 outputFormat := defaultIfError(flags.GetString(OutputFormatFlag)) 377 sortBy := defaultIfError(flags.GetString(SortByFlag)) 378 379 spin := spinner.New(spinner.CharSets[39], spinnerDuration, spinner.WithWriter(cmd.ErrOrStderr())) 380 spin.Prefix = prefix 381 382 return SearchConfig{ 383 SearchService: searchService, 384 ServURL: serverURL, 385 User: user, 386 OutputFormat: outputFormat, 387 VerifyTLS: verifyTLS, 388 FixedFlag: fixed, 389 Verbose: verbose, 390 Debug: debug, 391 SortBy: sortBy, 392 Spinner: spinnerState{spin, isSpinner}, 393 ResultWriter: cmd.OutOrStdout(), 394 }, nil 395 } 396 397 func defaultIfError[T any](out T, err error) T { 398 var defaultVal T 399 400 if err != nil { 401 return defaultVal 402 } 403 404 return out 405 } 406 407 func GetCliConfigOptions(cmd *cobra.Command) (bool, bool, error) { 408 configName, err := cmd.Flags().GetString(ConfigFlag) 409 if err != nil { 410 return false, false, err 411 } 412 413 if configName == "" { 414 return false, false, nil 415 } 416 417 home, err := os.UserHomeDir() 418 if err != nil { 419 return false, false, err 420 } 421 422 configDir := path.Join(home, "/.zot") 423 424 isSpinner, err := parseBooleanConfig(configDir, configName, showspinnerConfig) 425 if err != nil { 426 return false, false, err 427 } 428 429 verifyTLS, err := parseBooleanConfig(configDir, configName, verifyTLSConfig) 430 if err != nil { 431 return false, false, err 432 } 433 434 return isSpinner, verifyTLS, nil 435 } 436 437 func GetServerURLFromFlags(cmd *cobra.Command) (string, error) { 438 serverURL, err := cmd.Flags().GetString(URLFlag) 439 if err == nil && serverURL != "" { 440 return serverURL, nil 441 } 442 443 configName, err := cmd.Flags().GetString(ConfigFlag) 444 if err != nil { 445 return "", err 446 } 447 448 if configName == "" { 449 return "", fmt.Errorf("%w: specify either '--%s' or '--%s' flags", zerr.ErrNoURLProvided, URLFlag, ConfigFlag) 450 } 451 452 serverURL, err = ReadServerURLFromConfig(configName) 453 if err != nil { 454 return serverURL, fmt.Errorf("reading url from config failed: %w", err) 455 } 456 457 if serverURL == "" { 458 return "", fmt.Errorf("%w: url field from config is empty", zerr.ErrNoURLProvided) 459 } 460 461 if err := validateURL(serverURL); err != nil { 462 return "", err 463 } 464 465 return serverURL, nil 466 } 467 468 func ReadServerURLFromConfig(configName string) (string, error) { 469 home, err := os.UserHomeDir() 470 if err != nil { 471 return "", err 472 } 473 474 configDir := path.Join(home, "/.zot") 475 476 urlFromConfig, err := getConfigValue(configDir, configName, "url") 477 if err != nil { 478 return "", err 479 } 480 481 return urlFromConfig, nil 482 } 483 484 func GetSuggestionsString(suggestions []string) string { 485 if len(suggestions) > 0 { 486 return "\n\nDid you mean this?\n" + "\t" + strings.Join(suggestions, "\n\t") 487 } 488 489 return "" 490 } 491 492 func ShowSuggestionsIfUnknownCommand(cmd *cobra.Command, args []string) error { 493 if len(args) == 0 { 494 return cmd.Help() 495 } 496 497 cmd.SuggestionsMinimumDistance = 2 498 suggestions := GetSuggestionsString(cmd.SuggestionsFor(args[0])) 499 500 return fmt.Errorf("%w '%s' for '%s'%s", zerr.ErrUnknownSubcommand, args[0], cmd.Name(), suggestions) 501 }