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  }