mirror of
https://github.com/project-zot/zot.git
synced 2024-12-23 22:27:35 -05:00
f58597ade9
Signed-off-by: Alexei Dodon <adodon@cisco.com>
503 lines
12 KiB
Go
503 lines
12 KiB
Go
//go:build search
|
|
// +build search
|
|
|
|
package client
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/briandowns/spinner"
|
|
"github.com/spf13/cobra"
|
|
|
|
zerr "zotregistry.io/zot/errors"
|
|
"zotregistry.io/zot/pkg/api/constants"
|
|
"zotregistry.io/zot/pkg/cli/cmdflags"
|
|
)
|
|
|
|
const (
|
|
sizeColumn = "SIZE"
|
|
)
|
|
|
|
func ref[T any](input T) *T {
|
|
ref := input
|
|
|
|
return &ref
|
|
}
|
|
|
|
func fetchImageDigest(repo, ref, username, password string, config searchConfig) (string, error) {
|
|
url, err := combineServerAndEndpointURL(config.servURL, fmt.Sprintf("/v2/%s/manifests/%s", repo, ref))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
res, err := makeHEADRequest(context.Background(), url, username, password, config.verifyTLS, false)
|
|
|
|
digestStr := res.Get(constants.DistContentDigestKey)
|
|
|
|
return digestStr, err
|
|
}
|
|
|
|
func collectResults(config searchConfig, wg *sync.WaitGroup, imageErr chan stringResult,
|
|
cancel context.CancelFunc, printHeader printHeader, errCh chan error,
|
|
) {
|
|
var foundResult bool
|
|
|
|
defer wg.Done()
|
|
config.spinner.startSpinner()
|
|
|
|
for {
|
|
select {
|
|
case result, ok := <-imageErr:
|
|
config.spinner.stopSpinner()
|
|
|
|
if !ok {
|
|
cancel()
|
|
|
|
return
|
|
}
|
|
|
|
if result.Err != nil {
|
|
cancel()
|
|
errCh <- result.Err
|
|
|
|
return
|
|
}
|
|
|
|
if !foundResult && (config.outputFormat == defaultOutputFormat || config.outputFormat == "") {
|
|
var builder strings.Builder
|
|
|
|
printHeader(&builder, config.verbose, 0, 0, 0)
|
|
fmt.Fprint(config.resultWriter, builder.String())
|
|
}
|
|
|
|
foundResult = true
|
|
|
|
fmt.Fprint(config.resultWriter, result.StrValue)
|
|
case <-time.After(waitTimeout):
|
|
config.spinner.stopSpinner()
|
|
cancel()
|
|
|
|
errCh <- zerr.ErrCLITimeout
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func getUsernameAndPassword(user string) (string, string) {
|
|
if strings.Contains(user, ":") {
|
|
split := strings.Split(user, ":")
|
|
|
|
return split[0], split[1]
|
|
}
|
|
|
|
return "", ""
|
|
}
|
|
|
|
type spinnerState struct {
|
|
spinner *spinner.Spinner
|
|
enabled bool
|
|
}
|
|
|
|
func (spinner *spinnerState) startSpinner() {
|
|
if spinner.enabled {
|
|
spinner.spinner.Start()
|
|
}
|
|
}
|
|
|
|
func (spinner *spinnerState) stopSpinner() {
|
|
if spinner.enabled && spinner.spinner.Active() {
|
|
spinner.spinner.Stop()
|
|
}
|
|
}
|
|
|
|
const (
|
|
waitTimeout = httpTimeout + 5*time.Second
|
|
)
|
|
|
|
type stringResult struct {
|
|
StrValue string
|
|
Err error
|
|
}
|
|
|
|
type printHeader func(writer io.Writer, verbose bool, maxImageNameLen, maxTagLen, maxPlatformLen int)
|
|
|
|
func printImageTableHeader(writer io.Writer, verbose bool, maxImageNameLen, maxTagLen, maxPlatformLen int) {
|
|
table := getImageTableWriter(writer)
|
|
|
|
table.SetColMinWidth(colImageNameIndex, imageNameWidth)
|
|
table.SetColMinWidth(colTagIndex, tagWidth)
|
|
table.SetColMinWidth(colPlatformIndex, platformWidth)
|
|
table.SetColMinWidth(colDigestIndex, digestWidth)
|
|
table.SetColMinWidth(colSizeIndex, sizeWidth)
|
|
table.SetColMinWidth(colIsSignedIndex, isSignedWidth)
|
|
|
|
if verbose {
|
|
table.SetColMinWidth(colConfigIndex, configWidth)
|
|
table.SetColMinWidth(colLayersIndex, layersWidth)
|
|
}
|
|
|
|
row := make([]string, 8) //nolint:gomnd
|
|
|
|
// adding spaces so that repository and tag columns are aligned
|
|
// in case the name/tag are fully shown and too long
|
|
var offset string
|
|
if maxImageNameLen > len("REPOSITORY") {
|
|
offset = strings.Repeat(" ", maxImageNameLen-len("REPOSITORY"))
|
|
row[colImageNameIndex] = "REPOSITORY" + offset
|
|
} else {
|
|
row[colImageNameIndex] = "REPOSITORY"
|
|
}
|
|
|
|
if maxTagLen > len("TAG") {
|
|
offset = strings.Repeat(" ", maxTagLen-len("TAG"))
|
|
row[colTagIndex] = "TAG" + offset
|
|
} else {
|
|
row[colTagIndex] = "TAG"
|
|
}
|
|
|
|
if maxPlatformLen > len("OS/ARCH") {
|
|
offset = strings.Repeat(" ", maxPlatformLen-len("OS/ARCH"))
|
|
row[colPlatformIndex] = "OS/ARCH" + offset
|
|
} else {
|
|
row[colPlatformIndex] = "OS/ARCH"
|
|
}
|
|
|
|
row[colDigestIndex] = "DIGEST"
|
|
row[colSizeIndex] = sizeColumn
|
|
row[colIsSignedIndex] = "SIGNED"
|
|
|
|
if verbose {
|
|
row[colConfigIndex] = "CONFIG"
|
|
row[colLayersIndex] = "LAYERS"
|
|
}
|
|
|
|
table.Append(row)
|
|
table.Render()
|
|
}
|
|
|
|
func printCVETableHeader(writer io.Writer) {
|
|
table := getCVETableWriter(writer)
|
|
row := make([]string, 3) //nolint:gomnd
|
|
row[colCVEIDIndex] = "ID"
|
|
row[colCVESeverityIndex] = "SEVERITY"
|
|
row[colCVETitleIndex] = "TITLE"
|
|
|
|
table.Append(row)
|
|
table.Render()
|
|
}
|
|
|
|
func printReferrersTableHeader(config searchConfig, writer io.Writer, maxArtifactTypeLen int) {
|
|
if config.outputFormat != "" && config.outputFormat != defaultOutputFormat {
|
|
return
|
|
}
|
|
|
|
table := getReferrersTableWriter(writer)
|
|
|
|
table.SetColMinWidth(refArtifactTypeIndex, maxArtifactTypeLen)
|
|
table.SetColMinWidth(refDigestIndex, digestWidth)
|
|
table.SetColMinWidth(refSizeIndex, sizeWidth)
|
|
|
|
row := make([]string, refRowWidth)
|
|
|
|
// adding spaces so that repository and tag columns are aligned
|
|
// in case the name/tag are fully shown and too long
|
|
var offset string
|
|
|
|
if maxArtifactTypeLen > len("ARTIFACT TYPE") {
|
|
offset = strings.Repeat(" ", maxArtifactTypeLen-len("ARTIFACT TYPE"))
|
|
row[refArtifactTypeIndex] = "ARTIFACT TYPE" + offset
|
|
} else {
|
|
row[refArtifactTypeIndex] = "ARTIFACT TYPE"
|
|
}
|
|
|
|
row[refDigestIndex] = "DIGEST"
|
|
row[refSizeIndex] = sizeColumn
|
|
|
|
table.Append(row)
|
|
table.Render()
|
|
}
|
|
|
|
func printRepoTableHeader(writer io.Writer, repoMaxLen, maxTimeLen int, verbose bool) {
|
|
table := getRepoTableWriter(writer)
|
|
|
|
table.SetColMinWidth(repoNameIndex, repoMaxLen)
|
|
table.SetColMinWidth(repoSizeIndex, sizeWidth)
|
|
table.SetColMinWidth(repoLastUpdatedIndex, maxTimeLen)
|
|
table.SetColMinWidth(repoDownloadsIndex, sizeWidth)
|
|
table.SetColMinWidth(repoStarsIndex, sizeWidth)
|
|
|
|
if verbose {
|
|
table.SetColMinWidth(repoPlatformsIndex, platformWidth)
|
|
}
|
|
|
|
row := make([]string, repoRowWidth)
|
|
|
|
// adding spaces so that repository and tag columns are aligned
|
|
// in case the name/tag are fully shown and too long
|
|
var offset string
|
|
|
|
if repoMaxLen > len("NAME") {
|
|
offset = strings.Repeat(" ", repoMaxLen-len("NAME"))
|
|
row[repoNameIndex] = "NAME" + offset
|
|
} else {
|
|
row[repoNameIndex] = "NAME"
|
|
}
|
|
|
|
if repoMaxLen > len("LAST UPDATED") {
|
|
offset = strings.Repeat(" ", repoMaxLen-len("LAST UPDATED"))
|
|
row[repoLastUpdatedIndex] = "LAST UPDATED" + offset
|
|
} else {
|
|
row[repoLastUpdatedIndex] = "LAST UPDATED"
|
|
}
|
|
|
|
row[repoSizeIndex] = sizeColumn
|
|
row[repoDownloadsIndex] = "DOWNLOADS"
|
|
row[repoStarsIndex] = "STARS"
|
|
|
|
if verbose {
|
|
row[repoPlatformsIndex] = "PLATFORMS"
|
|
}
|
|
|
|
table.Append(row)
|
|
table.Render()
|
|
}
|
|
|
|
func printReferrersResult(config searchConfig, referrersList referrersResult, maxArtifactTypeLen int) error {
|
|
out, err := referrersList.string(config.outputFormat, maxArtifactTypeLen)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprint(config.resultWriter, out)
|
|
|
|
return nil
|
|
}
|
|
|
|
func printImageResult(config searchConfig, imageList []imageStruct) error {
|
|
var builder strings.Builder
|
|
maxImgNameLen := 0
|
|
maxTagLen := 0
|
|
maxPlatformLen := 0
|
|
|
|
if len(imageList) > 0 {
|
|
for i := range imageList {
|
|
if maxImgNameLen < len(imageList[i].RepoName) {
|
|
maxImgNameLen = len(imageList[i].RepoName)
|
|
}
|
|
|
|
if maxTagLen < len(imageList[i].Tag) {
|
|
maxTagLen = len(imageList[i].Tag)
|
|
}
|
|
|
|
for j := range imageList[i].Manifests {
|
|
platform := imageList[i].Manifests[j].Platform.Os + "/" + imageList[i].Manifests[j].Platform.Arch
|
|
|
|
if maxPlatformLen < len(platform) {
|
|
maxPlatformLen = len(platform)
|
|
}
|
|
}
|
|
}
|
|
|
|
if config.outputFormat == defaultOutputFormat || config.outputFormat == "" {
|
|
printImageTableHeader(&builder, config.verbose, maxImgNameLen, maxTagLen, maxPlatformLen)
|
|
}
|
|
|
|
fmt.Fprint(config.resultWriter, builder.String())
|
|
}
|
|
|
|
for i := range imageList {
|
|
img := imageList[i]
|
|
verbose := config.verbose
|
|
|
|
out, err := img.string(config.outputFormat, maxImgNameLen, maxTagLen, maxPlatformLen, verbose)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprint(config.resultWriter, out)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func printRepoResults(config searchConfig, repoList []repoStruct) error {
|
|
maxRepoNameLen := 0
|
|
maxTimeLen := 0
|
|
|
|
for _, repo := range repoList {
|
|
if maxRepoNameLen < len(repo.Name) {
|
|
maxRepoNameLen = len(repo.Name)
|
|
}
|
|
|
|
if maxTimeLen < len(repo.LastUpdated.String()) {
|
|
maxTimeLen = len(repo.LastUpdated.String())
|
|
}
|
|
}
|
|
|
|
if len(repoList) > 0 && (config.outputFormat == defaultOutputFormat || config.outputFormat == "") {
|
|
printRepoTableHeader(config.resultWriter, maxRepoNameLen, maxTimeLen, config.verbose)
|
|
}
|
|
|
|
for _, repo := range repoList {
|
|
out, err := repo.string(config.outputFormat, maxRepoNameLen, maxTimeLen, config.verbose)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprint(config.resultWriter, out)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func GetSearchConfigFromFlags(cmd *cobra.Command, searchService SearchService) (searchConfig, error) {
|
|
serverURL, err := GetServerURLFromFlags(cmd)
|
|
if err != nil {
|
|
return searchConfig{}, err
|
|
}
|
|
|
|
isSpinner, verifyTLS, err := GetCliConfigOptions(cmd)
|
|
if err != nil {
|
|
return searchConfig{}, err
|
|
}
|
|
|
|
flags := cmd.Flags()
|
|
user := defaultIfError(flags.GetString(cmdflags.UserFlag))
|
|
fixed := defaultIfError(flags.GetBool(cmdflags.FixedFlag))
|
|
debug := defaultIfError(flags.GetBool(cmdflags.DebugFlag))
|
|
verbose := defaultIfError(flags.GetBool(cmdflags.VerboseFlag))
|
|
outputFormat := defaultIfError(flags.GetString(cmdflags.OutputFormatFlag))
|
|
sortBy := defaultIfError(flags.GetString(cmdflags.SortByFlag))
|
|
|
|
spin := spinner.New(spinner.CharSets[39], spinnerDuration, spinner.WithWriter(cmd.ErrOrStderr()))
|
|
spin.Prefix = prefix
|
|
|
|
return searchConfig{
|
|
searchService: searchService,
|
|
servURL: serverURL,
|
|
user: user,
|
|
outputFormat: outputFormat,
|
|
verifyTLS: verifyTLS,
|
|
fixedFlag: fixed,
|
|
verbose: verbose,
|
|
debug: debug,
|
|
sortBy: sortBy,
|
|
spinner: spinnerState{spin, isSpinner},
|
|
resultWriter: cmd.OutOrStdout(),
|
|
}, nil
|
|
}
|
|
|
|
func defaultIfError[T any](out T, err error) T {
|
|
var defaultVal T
|
|
|
|
if err != nil {
|
|
return defaultVal
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func GetCliConfigOptions(cmd *cobra.Command) (bool, bool, error) {
|
|
configName, err := cmd.Flags().GetString(cmdflags.ConfigFlag)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
|
|
if configName == "" {
|
|
return false, false, nil
|
|
}
|
|
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
|
|
configDir := path.Join(home, "/.zot")
|
|
|
|
isSpinner, err := parseBooleanConfig(configDir, configName, showspinnerConfig)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
|
|
verifyTLS, err := parseBooleanConfig(configDir, configName, verifyTLSConfig)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
|
|
return isSpinner, verifyTLS, nil
|
|
}
|
|
|
|
func GetServerURLFromFlags(cmd *cobra.Command) (string, error) {
|
|
serverURL, err := cmd.Flags().GetString(cmdflags.URLFlag)
|
|
if err == nil && serverURL != "" {
|
|
return serverURL, nil
|
|
}
|
|
|
|
configName, err := cmd.Flags().GetString(cmdflags.ConfigFlag)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if configName == "" {
|
|
return "", fmt.Errorf("%w: specify either '--%s' or '--%s' flags", zerr.ErrNoURLProvided, cmdflags.URLFlag,
|
|
cmdflags.ConfigFlag)
|
|
}
|
|
|
|
serverURL, err = ReadServerURLFromConfig(configName)
|
|
if err != nil {
|
|
return serverURL, fmt.Errorf("reading url from config failed: %w", err)
|
|
}
|
|
|
|
if serverURL == "" {
|
|
return "", fmt.Errorf("%w: url field from config is empty", zerr.ErrNoURLProvided)
|
|
}
|
|
|
|
if err := validateURL(serverURL); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return serverURL, nil
|
|
}
|
|
|
|
func ReadServerURLFromConfig(configName string) (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
configDir := path.Join(home, "/.zot")
|
|
|
|
urlFromConfig, err := getConfigValue(configDir, configName, "url")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return urlFromConfig, nil
|
|
}
|
|
|
|
func GetSuggestionsString(suggestions []string) string {
|
|
if len(suggestions) > 0 {
|
|
return "\n\nDid you mean this?\n" + "\t" + strings.Join(suggestions, "\n\t")
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func ShowSuggestionsIfUnknownCommand(cmd *cobra.Command, args []string) error {
|
|
if len(args) == 0 {
|
|
return cmd.Help()
|
|
}
|
|
|
|
cmd.SuggestionsMinimumDistance = 2
|
|
suggestions := GetSuggestionsString(cmd.SuggestionsFor(args[0]))
|
|
|
|
return fmt.Errorf("%w '%s' for '%s'%s", zerr.ErrUnknownSubcommand, args[0], cmd.Name(), suggestions)
|
|
}
|