cli: add config and images command

Extends the existing zot CLI to add commands for listing all images and
their details on a zot server.
Listing all images introduces the need for configurations.

Each configuration has a name and URL at the least. Check 'zot config
-h' for more details.

The user can specify the URL of zot server explicitly while running the
command or configure a URL and pass it directly.

Adding a configuration:
zot config add aci-zot <zot-url>

Run 'zot config --help' for more.

Listing all images:
zot images --url <zot-url>

Pass a config instead of the url:
zot images <config-name>

Filter the list of images by image name:
zot images <config-name> --name <image-name>

Run 'zot images --help' for all details

- Stores configurations in '$HOME/.zot' file

Add CLI README
This commit is contained in:
Tanmay Naik
2020-06-16 21:52:40 -04:00
parent 4a1519bb1d
commit ad684ac44b
17 changed files with 2169 additions and 84 deletions
+27 -3
View File
@@ -2,26 +2,50 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "go_default_library",
srcs = ["root.go"],
srcs = [
"client.go",
"config_cmd.go",
"image_cmd.go",
"root.go",
"searcher.go",
"service.go",
],
importpath = "github.com/anuvu/zot/pkg/cli",
visibility = ["//visibility:public"],
deps = [
"//errors:go_default_library",
"//pkg/api:go_default_library",
"//pkg/storage:go_default_library",
"@com_github_briandowns_spinner//:go_default_library",
"@com_github_dustin_go_humanize//:go_default_library",
"@com_github_json_iterator_go//:go_default_library",
"@com_github_mitchellh_mapstructure//:go_default_library",
"@com_github_olekukonko_tablewriter//:go_default_library",
"@com_github_opencontainers_distribution_spec//:go_default_library",
"@com_github_rs_zerolog//log:go_default_library",
"@com_github_spf13_cobra//:go_default_library",
"@com_github_spf13_viper//:go_default_library",
"@in_gopkg_yaml_v2//:go_default_library",
],
)
go_test(
name = "go_default_test",
timeout = "short",
srcs = ["root_test.go"],
srcs = [
"config_cmd_test.go",
"image_cmd_test.go",
"root_test.go",
],
embed = [":go_default_library"],
race = "on",
deps = ["@com_github_smartystreets_goconvey//convey:go_default_library"],
deps = [
"//errors:go_default_library",
"//pkg/api:go_default_library",
"//pkg/compliance/v1_0_0:go_default_library",
"@com_github_opencontainers_go_digest//:go_default_library",
"@com_github_opencontainers_image_spec//specs-go/v1:go_default_library",
"@com_github_smartystreets_goconvey//convey:go_default_library",
"@in_gopkg_resty_v1//:go_default_library",
],
)
+165
View File
@@ -0,0 +1,165 @@
package cli
import (
"context"
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"net/url"
"strings"
"sync"
"time"
zotErrors "github.com/anuvu/zot/errors"
)
var httpClient *http.Client = createHTTPClient() //nolint: gochecknoglobals
const httpTimeout = 5 * time.Second
func createHTTPClient() *http.Client {
return &http.Client{
Timeout: httpTimeout,
}
}
func makeGETRequest(url, username, password string, resultsPtr interface{}) (http.Header, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(username, password)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusUnauthorized {
return nil, zotErrors.ErrUnauthorizedAccess
}
bodyBytes, _ := ioutil.ReadAll(resp.Body)
return nil, errors.New(string(bodyBytes)) //nolint: goerr113
}
if err := json.NewDecoder(resp.Body).Decode(resultsPtr); err != nil {
return nil, err
}
return resp.Header, nil
}
func isURL(str string) bool {
u, err := url.Parse(str)
return err == nil && u.Scheme != "" && u.Host != ""
} // from https://stackoverflow.com/a/55551215
type requestsPool struct {
jobs chan *manifestJob
done chan struct{}
waitGroup *sync.WaitGroup
outputCh chan imageListResult
context context.Context
}
type manifestJob struct {
url string
username string
password string
outputFormat string
imageName string
tagName string
manifestResp manifestResponse
}
const rateLimiterBuffer = 5000
func newSmoothRateLimiter(ctx context.Context, wg *sync.WaitGroup, op chan imageListResult) *requestsPool {
ch := make(chan *manifestJob, rateLimiterBuffer)
return &requestsPool{
jobs: ch,
done: make(chan struct{}),
waitGroup: wg,
outputCh: op,
context: ctx,
}
}
// block every "rateLimit" time duration.
const rateLimit = 100 * time.Millisecond
func (p *requestsPool) startRateLimiter() {
p.waitGroup.Done()
throttle := time.NewTicker(rateLimit).C
for {
select {
case job := <-p.jobs:
go p.doJob(job)
case <-p.done:
return
}
<-throttle
}
}
func (p *requestsPool) doJob(job *manifestJob) {
defer p.waitGroup.Done()
header, err := makeGETRequest(job.url, job.username, job.password, &job.manifestResp)
if err != nil {
if isContextDone(p.context) {
return
}
p.outputCh <- imageListResult{"", err}
}
digest := header.Get("docker-content-digest")
digest = strings.TrimPrefix(digest, "sha256:")
var size uint64
for _, layer := range job.manifestResp.Layers {
size += layer.Size
}
image := &imageStruct{}
image.Name = job.imageName
image.Tags = []tags{
{
Name: job.tagName,
Digest: digest,
Size: size,
},
}
str, err := image.string(job.outputFormat)
if err != nil {
if isContextDone(p.context) {
return
}
p.outputCh <- imageListResult{"", err}
return
}
if isContextDone(p.context) {
return
}
p.outputCh <- imageListResult{str, nil}
}
func (p *requestsPool) submitJob(job *manifestJob) {
p.jobs <- job
}
+355
View File
@@ -0,0 +1,355 @@
package cli
import (
"errors"
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
"text/tabwriter"
jsoniter "github.com/json-iterator/go"
zotErrors "github.com/anuvu/zot/errors"
"github.com/spf13/cobra"
)
func NewConfigCommand(configPath string) *cobra.Command {
var isListing bool
var isReset bool
var configCmd = &cobra.Command{
Use: "config <config-name> [variable] [value]",
Example: examples,
Short: "Configure zot CLI",
Long: `Configure default parameters for CLI`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
switch len(args) {
case noArgs:
if isListing { // zot config -l
res, err := getConfigNames(configPath)
if err != nil {
return err
}
fmt.Fprint(cmd.OutOrStdout(), res)
return nil
}
return zotErrors.ErrInvalidArgs
case oneArg:
// zot config <name> -l
if isListing {
res, err := getAllConfig(configPath, args[0])
if err != nil {
return err
}
fmt.Fprint(cmd.OutOrStdout(), res)
return nil
}
return zotErrors.ErrInvalidArgs
case twoArgs:
if isReset { // zot config <name> <key> --reset
return resetConfigValue(configPath, args[0], args[1])
}
// zot config <name> <key>
res, err := getConfigValue(configPath, args[0], args[1])
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), res)
case threeArgs:
//zot config <name> <key> <value>
if err := setConfigValue(configPath, args[0], args[1], args[2]); err != nil {
return err
}
default:
return zotErrors.ErrInvalidArgs
}
return nil
},
}
configCmd.Flags().BoolVarP(&isListing, "list", "l", false, "List configurations")
configCmd.Flags().BoolVar(&isReset, "reset", false, "Reset a variable value")
configCmd.SetUsageTemplate(configCmd.UsageTemplate() + supportedOptions)
configCmd.AddCommand(NewConfigAddCommand(configPath))
return configCmd
}
func NewConfigAddCommand(configPath string) *cobra.Command {
var configAddCmd = &cobra.Command{
Use: "add <config-name> <url>",
Short: "Add configuration for a zot URL",
Long: `Configure CLI for interaction with a zot server`,
Args: cobra.ExactArgs(twoArgs),
RunE: func(cmd *cobra.Command, args []string) error {
// zot config add <config-name> <url>
err := addConfig(configPath, args[0], args[1])
if err != nil {
return err
}
return nil
},
}
return configAddCmd
}
func getConfigMapFromFile(filePath string) ([]interface{}, error) {
file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0644)
if err != nil {
return nil, err
}
file.Close()
data, err := ioutil.ReadFile(filePath)
if err != nil {
return nil, err
}
var jsonMap map[string]interface{}
var json = jsoniter.ConfigCompatibleWithStandardLibrary
_ = json.Unmarshal(data, &jsonMap)
if jsonMap["configs"] == nil {
return nil, ErrEmptyJSON
}
return jsonMap["configs"].([]interface{}), nil
}
func saveConfigMapToFile(filePath string, configMap []interface{}) error {
var json = jsoniter.ConfigCompatibleWithStandardLibrary
listMap := make(map[string]interface{})
listMap["configs"] = configMap
marshalled, err := json.Marshal(&listMap)
if err != nil {
return err
}
if err := ioutil.WriteFile(filePath, marshalled, 0600); err != nil {
return err
}
return nil
}
func getConfigNames(configPath string) (string, error) {
configs, err := getConfigMapFromFile(configPath)
if err != nil {
if errors.Is(err, ErrEmptyJSON) {
return "", nil
}
return "", err
}
var builder strings.Builder
writer := tabwriter.NewWriter(&builder, 0, 8, 1, '\t', tabwriter.AlignRight)
for _, val := range configs {
configMap := val.(map[string]interface{})
fmt.Fprintf(writer, "%s\t%s\n", configMap[nameKey], configMap["url"])
}
err = writer.Flush()
if err != nil {
return "", err
}
return builder.String(), nil
}
func addConfig(configPath, configName, url string) error {
configs, err := getConfigMapFromFile(configPath)
if err != nil && !errors.Is(err, ErrEmptyJSON) {
return err
}
if !isURL(url) {
return zotErrors.ErrInvalidURL
}
configMap := make(map[string]interface{})
configMap["url"] = url
configMap[nameKey] = configName
configs = append(configs, configMap)
err = saveConfigMapToFile(configPath, configs)
if err != nil {
return err
}
return nil
}
func getConfigValue(configPath, configName, key string) (string, error) {
configs, err := getConfigMapFromFile(configPath)
if err != nil {
if errors.Is(err, ErrEmptyJSON) {
return "", zotErrors.ErrConfigNotFound
}
return "", err
}
for _, val := range configs {
configMap := val.(map[string]interface{})
name := configMap[nameKey]
if name == configName {
if configMap[key] == nil {
return "", nil
}
return fmt.Sprintf("%v", configMap[key]), nil
}
}
return "", zotErrors.ErrConfigNotFound
}
func resetConfigValue(configPath, configName, key string) error {
if key == "url" || key == nameKey {
return zotErrors.ErrCannotResetConfigKey
}
configs, err := getConfigMapFromFile(configPath)
if err != nil {
if errors.Is(err, ErrEmptyJSON) {
return zotErrors.ErrConfigNotFound
}
return err
}
for _, val := range configs {
configMap := val.(map[string]interface{})
name := configMap[nameKey]
if name == configName {
delete(configMap, key)
err = saveConfigMapToFile(configPath, configs)
if err != nil {
return err
}
return nil
}
}
return zotErrors.ErrConfigNotFound
}
func setConfigValue(configPath, configName, key, value string) error {
if key == nameKey {
return zotErrors.ErrIllegalConfigKey
}
configs, err := getConfigMapFromFile(configPath)
if err != nil {
if errors.Is(err, ErrEmptyJSON) {
return zotErrors.ErrConfigNotFound
}
return err
}
for _, val := range configs {
configMap := val.(map[string]interface{})
name := configMap[nameKey]
if name == configName {
boolVal, err := strconv.ParseBool(value)
if err == nil {
configMap[key] = boolVal
} else {
configMap[key] = value
}
err = saveConfigMapToFile(configPath, configs)
if err != nil {
return err
}
return nil
}
}
return zotErrors.ErrConfigNotFound
}
func getAllConfig(configPath, configName string) (string, error) {
configs, err := getConfigMapFromFile(configPath)
if err != nil {
if errors.Is(err, ErrEmptyJSON) {
return "", nil
}
return "", err
}
var builder strings.Builder
for _, value := range configs {
configMap := value.(map[string]interface{})
name := configMap[nameKey]
if name == configName {
for key, val := range configMap {
if key == nameKey {
continue
}
fmt.Fprintf(&builder, "%s = %v\n", key, val)
}
return builder.String(), nil
}
}
return "", zotErrors.ErrConfigNotFound
}
const (
examples = ` zot config add main https://zot-foo.com:8080
zot config main url
zot config main --list
zot config --list`
supportedOptions = `
Useful variables:
url zot server URL
showspinner show spinner while loading data [true/false]`
nameKey = "_name"
noArgs = 0
oneArg = 1
twoArgs = 2
threeArgs = 3
)
var (
ErrEmptyJSON = errors.New("cli: config json is empty")
)
+301
View File
@@ -0,0 +1,301 @@
package cli //nolint:testpackage
import (
"bytes"
"io/ioutil"
"os"
"strings"
"testing"
zotErrors "github.com/anuvu/zot/errors"
. "github.com/smartystreets/goconvey/convey"
)
func TestConfigCmdBasics(t *testing.T) {
Convey("Test config help", t, func() {
args := []string{"--help"}
configPath := makeConfigFile("showspinner = false")
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(buff.String(), ShouldContainSubstring, "Usage")
So(err, ShouldBeNil)
Convey("with the shorthand", func() {
args[0] = "-h"
configPath := makeConfigFile("showspinner = false")
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(buff.String(), ShouldContainSubstring, "Usage")
So(err, ShouldBeNil)
})
})
Convey("Test config no args", t, func() {
args := []string{}
configPath := makeConfigFile("showspinner = false")
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(buff.String(), ShouldContainSubstring, "Usage")
So(err, ShouldNotBeNil)
})
}
func TestConfigCmdMain(t *testing.T) {
Convey("Test add config", t, func() {
args := []string{"add", "configtest1", "https://test-url.com"}
file := makeConfigFile("")
defer os.Remove(file)
cmd := NewConfigCommand(file)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
_ = cmd.Execute()
actual, err := ioutil.ReadFile(file)
if err != nil {
panic(err)
}
actualStr := string(actual)
So(actualStr, ShouldContainSubstring, "configtest1")
So(actualStr, ShouldContainSubstring, "https://test-url.com")
})
Convey("Test add config with invalid URL", t, func() {
args := []string{"add", "configtest1", "test..com"}
file := makeConfigFile("")
defer os.Remove(file)
cmd := NewConfigCommand(file)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldNotBeNil)
So(err, ShouldEqual, zotErrors.ErrInvalidURL)
})
Convey("Test fetch all config", t, func() {
args := []string{"--list"}
configPath := makeConfigFile(`{"configs":[{"_name":"configtest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(buff.String(), ShouldContainSubstring, "https://test-url.com")
So(err, ShouldBeNil)
Convey("with the shorthand", func() {
args := []string{"-l"}
configPath := makeConfigFile(`{"configs":[{"_name":"configtest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(buff.String(), ShouldContainSubstring, "https://test-url.com")
So(err, ShouldBeNil)
})
Convey("From empty file", func() {
args := []string{"-l"}
configPath := makeConfigFile(``)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldBeNil)
So(strings.TrimSpace(buff.String()), ShouldEqual, "")
})
})
Convey("Test fetch a config", t, func() {
args := []string{"configtest", "--list"}
configPath := makeConfigFile(`{"configs":[{"_name":"configtest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(buff.String(), ShouldContainSubstring, "url = https://test-url.com")
So(buff.String(), ShouldContainSubstring, "showspinner = false")
So(err, ShouldBeNil)
Convey("with the shorthand", func() {
args := []string{"configtest", "-l"}
configPath := makeConfigFile(`{"configs":[{"_name":"configtest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(buff.String(), ShouldContainSubstring, "url = https://test-url.com")
So(buff.String(), ShouldContainSubstring, "showspinner = false")
So(err, ShouldBeNil)
})
Convey("From empty file", func() {
args := []string{"configtest", "-l"}
configPath := makeConfigFile(``)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldBeNil)
So(strings.TrimSpace(buff.String()), ShouldEqual, "")
})
})
Convey("Test fetch a config val", t, func() {
args := []string{"configtest", "url"}
configPath := makeConfigFile(`{"configs":[{"_name":"configtest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(buff.String(), ShouldEqual, "https://test-url.com\n")
So(err, ShouldBeNil)
Convey("From empty file", func() {
args := []string{"configtest", "url"}
configPath := makeConfigFile(``)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(buff)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldNotBeNil)
So(buff.String(), ShouldContainSubstring, "does not exist")
})
})
Convey("Test add a config val", t, func() {
args := []string{"configtest", "showspinner", "false"}
configPath := makeConfigFile(`{"configs":[{"_name":"configtest","url":"https://test-url.com"}]}`)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldBeNil)
actual, err := ioutil.ReadFile(configPath)
if err != nil {
panic(err)
}
actualStr := string(actual)
So(actualStr, ShouldContainSubstring, "https://test-url.com")
So(actualStr, ShouldContainSubstring, `"showspinner":false`)
So(buff.String(), ShouldEqual, "")
Convey("To an empty file", func() {
args := []string{"configtest", "showspinner", "false"}
configPath := makeConfigFile(``)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldNotBeNil)
So(buff.String(), ShouldContainSubstring, "does not exist")
})
})
Convey("Test overwrite a config", t, func() {
args := []string{"configtest", "url", "https://new-url.com"}
configPath := makeConfigFile(`{"configs":[{"_name":"configtest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldBeNil)
actual, err := ioutil.ReadFile(configPath)
if err != nil {
panic(err)
}
actualStr := string(actual)
So(actualStr, ShouldContainSubstring, `https://new-url.com`)
So(actualStr, ShouldContainSubstring, `"showspinner":false`)
So(actualStr, ShouldNotContainSubstring, `https://test-url.com`)
So(buff.String(), ShouldEqual, "")
})
Convey("Test reset a config val", t, func() {
args := []string{"configtest", "showspinner", "--reset"}
configPath := makeConfigFile(`{"configs":[{"_name":"configtest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldBeNil)
actual, err := ioutil.ReadFile(configPath)
if err != nil {
panic(err)
}
actualStr := string(actual)
So(actualStr, ShouldNotContainSubstring, "showspinner")
So(actualStr, ShouldContainSubstring, `"url":"https://test-url.com"`)
So(buff.String(), ShouldEqual, "")
})
Convey("Test reset a url", t, func() {
args := []string{"configtest", "url", "--reset"}
configPath := makeConfigFile(`{"configs":[{"_name":"configtest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewConfigCommand(configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldNotBeNil)
So(buff.String(), ShouldContainSubstring, "cannot reset")
})
}
+123
View File
@@ -0,0 +1,123 @@
package cli
import (
"strconv"
"time"
zotErrors "github.com/anuvu/zot/errors"
"github.com/briandowns/spinner"
"github.com/spf13/cobra"
)
func NewImageCommand(searchService ImageSearchService, configPath string) *cobra.Command {
searchImageParams := make(map[string]*string)
var servURL string
var user string
var outputFormat string
var imageCmd = &cobra.Command{
Use: "images [config-name]",
Short: "List hosted images",
Long: `List images hosted on zot`,
RunE: func(cmd *cobra.Command, args []string) error {
if servURL == "" {
if len(args) > 0 {
urlFromConfig, err := getConfigValue(configPath, args[0], "url")
if err != nil {
cmd.SilenceUsage = true
return err
}
if urlFromConfig == "" {
return zotErrors.ErrNoURLProvided
}
servURL = urlFromConfig
} else {
return zotErrors.ErrNoURLProvided
}
}
var isSpinner bool
if len(args) > 0 {
var err error
isSpinner, err = isSpinnerEnabled(configPath, args[0])
if err != nil {
cmd.SilenceUsage = true
return err
}
} else {
isSpinner = true
}
err := searchImage(cmd, searchImageParams, searchService, &servURL, &user, &outputFormat, isSpinner)
if err != nil {
cmd.SilenceUsage = true
return err
}
return nil
},
}
setupCmdFlags(imageCmd, searchImageParams, &servURL, &user, &outputFormat)
imageCmd.SetUsageTemplate(imageCmd.UsageTemplate() + usageFooter)
return imageCmd
}
func isSpinnerEnabled(configPath, configName string) (bool, error) {
spinnerConfig, err := getConfigValue(configPath, configName, "showspinner")
if err != nil {
return false, err
}
if spinnerConfig == "" {
return true, nil // spinner is enabled by default
}
isSpinner, err := strconv.ParseBool(spinnerConfig)
if err != nil {
return false, err
}
return isSpinner, nil
}
func setupCmdFlags(imageCmd *cobra.Command, searchImageParams map[string]*string, servURL, user, outputFormat *string) {
searchImageParams["imageName"] = imageCmd.Flags().StringP("name", "n", "", "List image details by name")
imageCmd.Flags().StringVar(servURL, "url", "", "Specify zot server URL if config-name is not mentioned")
imageCmd.Flags().StringVarP(user, "user", "u", "", `User Credentials of zot server in "username:password" format`)
imageCmd.Flags().StringVarP(outputFormat, "output", "o", "", "Specify output format [text/json/yaml]")
}
func searchImage(cmd *cobra.Command, params map[string]*string,
service ImageSearchService, servURL, user, outputFormat *string, isSpinner bool) error {
spin := spinner.New(spinner.CharSets[39], spinnerDuration, spinner.WithWriter(cmd.ErrOrStderr()))
spin.Prefix = "Searching... "
for _, searcher := range getSearchers() {
found, err := searcher.search(params, service, servURL, user, outputFormat,
cmd.OutOrStdout(), spinnerState{spin, isSpinner})
if found {
if err != nil {
return err
}
return nil
}
}
return zotErrors.ErrInvalidFlagsCombination
}
const (
spinnerDuration = 150 * time.Millisecond
usageFooter = `
Run 'zot config -h' for details on [config-name] argument
`
)
+499
View File
@@ -0,0 +1,499 @@
package cli //nolint:testpackage
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"regexp"
"strings"
"sync"
"gopkg.in/resty.v1"
"testing"
"time"
zotErrors "github.com/anuvu/zot/errors"
"github.com/anuvu/zot/pkg/api"
"github.com/anuvu/zot/pkg/compliance/v1_0_0"
godigest "github.com/opencontainers/go-digest"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
. "github.com/smartystreets/goconvey/convey"
)
func TestSearchImageCmd(t *testing.T) {
Convey("Test image help", t, func() {
args := []string{"--help"}
configPath := makeConfigFile("")
defer os.Remove(configPath)
cmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(buff.String(), ShouldContainSubstring, "Usage")
So(err, ShouldBeNil)
Convey("with the shorthand", func() {
args[0] = "-h"
configPath := makeConfigFile("")
defer os.Remove(configPath)
cmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(buff.String(), ShouldContainSubstring, "Usage")
So(err, ShouldBeNil)
})
})
Convey("Test image no url", t, func() {
args := []string{"imagetest", "--name", "dummyIdRandom"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldNotBeNil)
})
Convey("Test image no params", t, func() {
args := []string{"imagetest", "--url", "someUrl"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldBeNil)
})
Convey("Test image invalid url", t, func() {
args := []string{"imagetest", "--name", "dummyImageName", "--url", "invalidUrl"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(searchService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(buff)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldNotBeNil)
So(err, ShouldEqual, zotErrors.ErrInvalidURL)
So(buff.String(), ShouldContainSubstring, "invalid URL format")
})
Convey("Test image invalid url port", t, func() {
args := []string{"imagetest", "--name", "dummyImageName", "--url", "http://localhost:99999"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(searchService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(buff)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldNotBeNil)
So(buff.String(), ShouldContainSubstring, "invalid port")
Convey("without flags", func() {
args := []string{"imagetest", "--url", "http://localhost:99999"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(searchService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(buff)
cmd.SetArgs(args)
err = cmd.Execute()
So(err, ShouldNotBeNil)
So(buff.String(), ShouldContainSubstring, "invalid port")
})
})
Convey("Test image unreachable", t, func() {
args := []string{"imagetest", "--name", "dummyImageName", "--url", "http://localhost:9999"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(searchService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(buff)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldNotBeNil)
})
Convey("Test image url from config", t, func() {
args := []string{"imagetest", "--name", "dummyImageName"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
So(strings.TrimSpace(str), ShouldEqual, "IMAGE NAME TAG DIGEST SIZE dummyImageName tag DigestsA 123kB")
So(err, ShouldBeNil)
})
Convey("Test image by name", t, func() {
args := []string{"imagetest", "--name", "dummyImageName", "--url", "someUrlImage"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","showspinner":false}]}`)
defer os.Remove(configPath)
imageCmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
imageCmd.SetOut(buff)
imageCmd.SetErr(ioutil.Discard)
imageCmd.SetArgs(args)
err := imageCmd.Execute()
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
So(strings.TrimSpace(str), ShouldEqual, "IMAGE NAME TAG DIGEST SIZE dummyImageName tag DigestsA 123kB")
So(err, ShouldBeNil)
Convey("using shorthand", func() {
args := []string{"imagetest", "-n", "dummyImageName", "--url", "someUrlImage"}
buff := bytes.NewBufferString("")
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","showspinner":false}]}`)
defer os.Remove(configPath)
imageCmd := NewImageCommand(new(mockService), configPath)
imageCmd.SetOut(buff)
imageCmd.SetErr(ioutil.Discard)
imageCmd.SetArgs(args)
err := imageCmd.Execute()
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
So(strings.TrimSpace(str), ShouldEqual, "IMAGE NAME TAG DIGEST SIZE dummyImageName tag DigestsA 123kB")
So(err, ShouldBeNil)
})
})
}
func TestOutputFormat(t *testing.T) {
Convey("Test text", t, func() {
args := []string{"imagetest", "--name", "dummyImageName", "-o", "text"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
So(strings.TrimSpace(str), ShouldEqual, "IMAGE NAME TAG DIGEST SIZE dummyImageName tag DigestsA 123kB")
So(err, ShouldBeNil)
})
Convey("Test json", t, func() {
args := []string{"imagetest", "--name", "dummyImageName", "-o", "json"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
So(strings.TrimSpace(str), ShouldEqual, `{ "name": "dummyImageName", "tags": [ { "name":`+
` "tag", "size": 123445, "digest": "DigestsAreReallyLong" } ] }`)
So(err, ShouldBeNil)
})
Convey("Test yaml", t, func() {
args := []string{"imagetest", "--name", "dummyImageName", "-o", "yaml"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
So(strings.TrimSpace(str), ShouldEqual, `name: dummyImageName tags: -`+
` name: tag size: 123445 digest: DigestsAreReallyLong`)
So(err, ShouldBeNil)
Convey("Test yml", func() {
args := []string{"imagetest", "--name", "dummyImageName", "-o", "yml"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(ioutil.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
So(strings.TrimSpace(str), ShouldEqual, "name: dummyImageName tags: - name: "+
"tag size: 123445 digest: DigestsAreReallyLong")
So(err, ShouldBeNil)
})
})
Convey("Test invalid", t, func() {
args := []string{"imagetest", "--name", "dummyImageName", "-o", "random"}
configPath := makeConfigFile(`{"configs":[{"_name":"imagetest","url":"https://test-url.com","showspinner":false}]}`)
defer os.Remove(configPath)
cmd := NewImageCommand(new(mockService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(buff)
cmd.SetArgs(args)
err := cmd.Execute()
So(err, ShouldNotBeNil)
So(buff.String(), ShouldContainSubstring, "invalid output format")
})
}
func TestServerResponse(t *testing.T) {
Convey("Test from real server", t, func() {
port := "8080"
url := "http://127.0.0.1:8080"
config := api.NewConfig()
config.HTTP.Port = port
c := api.NewController(config)
dir, err := ioutil.TempDir("", "oci-repo-test")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
c.Config.Storage.RootDirectory = dir
go func(controller *api.Controller) {
// this blocks
if err := controller.Run(); err != nil {
return
}
}(c)
// wait till ready
for {
_, err := resty.R().Get(url)
if err == nil {
break
}
time.Sleep(100 * time.Millisecond)
}
defer func(controller *api.Controller) {
ctx := context.Background()
_ = controller.Server.Shutdown(ctx)
}(c)
uploadManifest(url)
Convey("Test all images config url", func() {
args := []string{"imagetest"}
configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"imagetest","url":"%s","showspinner":false}]}`, url))
defer os.Remove(configPath)
cmd := NewImageCommand(new(searchService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(buff)
cmd.SetArgs(args)
err = cmd.Execute()
So(err, ShouldBeNil)
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
actual := strings.TrimSpace(str)
So(actual, ShouldContainSubstring, "IMAGE NAME TAG DIGEST SIZE")
So(actual, ShouldContainSubstring, "repo7 test:2.0 a0ca253b 15B")
So(actual, ShouldContainSubstring, "repo7 test:1.0 a0ca253b 15B")
})
Convey("Test image by name config url", func() {
args := []string{"imagetest", "--name", "repo7"}
configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"imagetest","url":"%s","showspinner":false}]}`, url))
defer os.Remove(configPath)
cmd := NewImageCommand(new(searchService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(buff)
cmd.SetArgs(args)
err = cmd.Execute()
So(err, ShouldBeNil)
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
actual := strings.TrimSpace(str)
So(actual, ShouldContainSubstring, "IMAGE NAME TAG DIGEST SIZE")
So(actual, ShouldContainSubstring, "repo7 test:2.0 a0ca253b 15B")
So(actual, ShouldContainSubstring, "repo7 test:1.0 a0ca253b 15B")
Convey("with shorthand", func() {
args := []string{"imagetest", "-n", "repo7"}
configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"imagetest","url":"%s","showspinner":false}]}`, url))
defer os.Remove(configPath)
cmd := NewImageCommand(new(searchService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(buff)
cmd.SetArgs(args)
err = cmd.Execute()
So(err, ShouldBeNil)
space := regexp.MustCompile(`\s+`)
str := space.ReplaceAllString(buff.String(), " ")
actual := strings.TrimSpace(str)
So(actual, ShouldContainSubstring, "IMAGE NAME TAG DIGEST SIZE")
So(actual, ShouldContainSubstring, "repo7 test:2.0 a0ca253b 15B")
So(actual, ShouldContainSubstring, "repo7 test:1.0 a0ca253b 15B")
})
})
Convey("Test image by name invalid name", func() {
args := []string{"imagetest", "--name", "repo777"}
configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"imagetest","url":"%s","showspinner":false}]}`, url))
defer os.Remove(configPath)
cmd := NewImageCommand(new(searchService), configPath)
buff := bytes.NewBufferString("")
cmd.SetOut(buff)
cmd.SetErr(buff)
cmd.SetArgs(args)
err = cmd.Execute()
So(err, ShouldNotBeNil)
actual := buff.String()
So(actual, ShouldContainSubstring, "unknown")
})
})
}
func uploadManifest(url string) {
// create a blob/layer
resp, _ := resty.R().Post(url + "/v2/repo7/blobs/uploads/")
loc := v1_0_0.Location(url, resp)
content := []byte("this is a blob5")
digest := godigest.FromBytes(content)
_, _ = resty.R().SetQueryParam("digest", digest.String()).
SetHeader("Content-Type", "application/octet-stream").SetBody(content).Put(loc)
// create a manifest
m := ispec.Manifest{
Config: ispec.Descriptor{
Digest: digest,
Size: int64(len(content)),
},
Layers: []ispec.Descriptor{
{
MediaType: "application/vnd.oci.image.layer.v1.tar",
Digest: digest,
Size: int64(len(content)),
},
},
}
m.SchemaVersion = 2
content, _ = json.Marshal(m)
_, _ = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json").
SetBody(content).Put(url + "/v2/repo7/manifests/test:1.0")
content = []byte("this is a blob5")
digest = godigest.FromBytes(content)
// create a manifest with same blob but a different tag
m = ispec.Manifest{
Config: ispec.Descriptor{
Digest: digest,
Size: int64(len(content)),
},
Layers: []ispec.Descriptor{
{
MediaType: "application/vnd.oci.image.layer.v1.tar",
Digest: digest,
Size: int64(len(content)),
},
},
}
m.SchemaVersion = 2
content, _ = json.Marshal(m)
_, _ = resty.R().SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json").
SetBody(content).Put(url + "/v2/repo7/manifests/test:2.0")
}
type mockService struct{}
func (service mockService) getAllImages(ctx context.Context, serverURL, username, password,
outputFormat string, channel chan imageListResult, wg *sync.WaitGroup) {
defer wg.Done()
image := &imageStruct{}
image.Name = "randomimageName"
image.Tags = []tags{
{
Name: "tag",
Digest: "DigestsAreReallyLong",
Size: 123445,
},
}
str, err := image.string(outputFormat)
if err != nil {
channel <- imageListResult{"", err}
return
}
channel <- imageListResult{str, nil}
}
func (service mockService) getImageByName(ctx context.Context, serverURL, username, password,
imageName, outputFormat string, channel chan imageListResult, wg *sync.WaitGroup) {
defer wg.Done()
image := &imageStruct{}
image.Name = imageName
image.Tags = []tags{
{
Name: "tag",
Digest: "DigestsAreReallyLong",
Size: 123445,
},
}
str, err := image.string(outputFormat)
if err != nil {
channel <- imageListResult{"", err}
return
}
channel <- imageListResult{str, nil}
}
func makeConfigFile(content string) string {
f, err := ioutil.TempFile("", "config-*.properties")
if err != nil {
panic(err)
}
defer f.Close()
text := []byte(content)
if err := ioutil.WriteFile(f.Name(), text, 0600); err != nil {
panic(err)
}
return f.Name()
}
+5 -1
View File
@@ -19,7 +19,7 @@ func metadataConfig(md *mapstructure.Metadata) viper.DecoderConfigOption {
}
}
func NewRootCmd() *cobra.Command {
func NewRootCmd(configPath string) *cobra.Command {
showVersion := false
config := api.NewConfig()
@@ -96,6 +96,10 @@ func NewRootCmd() *cobra.Command {
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(gcCmd)
rootCmd.AddCommand(NewConfigCommand(configPath))
rootCmd.AddCommand(NewImageCommand(NewImageSearchService(), configPath))
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "show the version and exit")
return rootCmd
+10 -9
View File
@@ -3,6 +3,7 @@ package cli_test
import (
"io/ioutil"
"os"
"path"
"testing"
"github.com/anuvu/zot/pkg/cli"
@@ -16,13 +17,13 @@ func TestUsage(t *testing.T) {
Convey("Test usage", t, func(c C) {
os.Args = []string{"cli_test", "help"}
err := cli.NewRootCmd().Execute()
err := cli.NewRootCmd(os.TempDir()).Execute()
So(err, ShouldBeNil)
})
Convey("Test version", t, func(c C) {
os.Args = []string{"cli_test", "--version"}
err := cli.NewRootCmd().Execute()
err := cli.NewRootCmd(os.TempDir()).Execute()
So(err, ShouldBeNil)
})
}
@@ -34,19 +35,19 @@ func TestServe(t *testing.T) {
Convey("Test serve help", t, func(c C) {
os.Args = []string{"cli_test", "serve", "-h"}
err := cli.NewRootCmd().Execute()
err := cli.NewRootCmd(os.TempDir()).Execute()
So(err, ShouldBeNil)
})
Convey("Test serve config", t, func(c C) {
Convey("unknown config", func(c C) {
os.Args = []string{"cli_test", "serve", "/tmp/x"}
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
os.Args = []string{"cli_test", "serve", path.Join(os.TempDir(), "/x")}
So(func() { _ = cli.NewRootCmd(os.TempDir()).Execute() }, ShouldPanic)
})
Convey("non-existent config", func(c C) {
os.Args = []string{"cli_test", "serve", "/tmp/x.yaml"}
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
os.Args = []string{"cli_test", "serve", path.Join(os.TempDir(), "/x.yaml")}
So(func() { _ = cli.NewRootCmd(os.TempDir()).Execute() }, ShouldPanic)
})
Convey("bad config", func(c C) {
@@ -59,7 +60,7 @@ func TestServe(t *testing.T) {
err = tmpfile.Close()
So(err, ShouldBeNil)
os.Args = []string{"cli_test", "serve", tmpfile.Name()}
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
So(func() { _ = cli.NewRootCmd(os.TempDir()).Execute() }, ShouldPanic)
})
})
}
@@ -71,7 +72,7 @@ func TestGC(t *testing.T) {
Convey("Test gc", t, func(c C) {
os.Args = []string{"cli_test", "garbage-collect", "-h"}
err := cli.NewRootCmd().Execute()
err := cli.NewRootCmd(os.TempDir()).Execute()
So(err, ShouldBeNil)
})
}
+215
View File
@@ -0,0 +1,215 @@
package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/briandowns/spinner"
)
func getSearchers() []searcher {
searchers := []searcher{
new(allImagesSearcher),
new(imageByNameSearcher),
}
return searchers
}
type searcher interface {
search(params map[string]*string, searchService ImageSearchService,
servURL, user, outputFormat *string, stdWriter io.Writer, spinner spinnerState) (bool, error)
}
func canSearch(params map[string]*string, requiredParams *set) bool {
for key, value := range params {
if requiredParams.contains(key) && *value == "" {
return false
} else if !requiredParams.contains(key) && *value != "" {
return false
}
}
return true
}
type allImagesSearcher struct{}
func (search allImagesSearcher) search(params map[string]*string, searchService ImageSearchService,
servURL, user, outputFormat *string, stdWriter io.Writer, spinner spinnerState) (bool, error) {
if !canSearch(params, newSet("")) {
return false, nil
}
username, password := getUsernameAndPassword(*user)
imageErr := make(chan imageListResult)
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go searchService.getAllImages(ctx, *servURL, username, password, *outputFormat, imageErr, &wg)
wg.Add(1)
var errCh chan error = make(chan error, 1)
go collectImages(outputFormat, stdWriter, &wg, imageErr, cancel, spinner, errCh)
wg.Wait()
select {
case err := <-errCh:
return true, err
default:
return true, nil
}
}
type imageByNameSearcher struct{}
func (search imageByNameSearcher) search(params map[string]*string,
searchService ImageSearchService, servURL, user, outputFormat *string,
stdWriter io.Writer, spinner spinnerState) (bool, error) {
if !canSearch(params, newSet("imageName")) {
return false, nil
}
username, password := getUsernameAndPassword(*user)
imageErr := make(chan imageListResult)
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go searchService.getImageByName(ctx, *servURL, username, password, *params["imageName"], *outputFormat, imageErr, &wg)
wg.Add(1)
var errCh chan error = make(chan error, 1)
go collectImages(outputFormat, stdWriter, &wg, imageErr, cancel, spinner, errCh)
wg.Wait()
select {
case err := <-errCh:
return true, err
default:
return true, nil
}
}
func collectImages(outputFormat *string, stdWriter io.Writer, wg *sync.WaitGroup,
imageErr chan imageListResult, cancel context.CancelFunc, spinner spinnerState, errCh chan error) {
var foundResult bool
defer wg.Done()
spinner.startSpinner()
for {
select {
case result := <-imageErr:
if result.Err != nil {
spinner.stopSpinner()
cancel()
errCh <- result.Err
return
}
if !foundResult && (*outputFormat == "text" || *outputFormat == "") {
spinner.stopSpinner()
var builder strings.Builder
printImageTableHeader(&builder)
fmt.Fprint(stdWriter, builder.String())
}
foundResult = true
fmt.Fprint(stdWriter, result.StrValue)
case <-time.After(waitTimeout):
cancel()
return
}
}
}
func getUsernameAndPassword(user string) (string, string) {
if strings.Contains(user, ":") {
split := strings.Split(user, ":")
return split[0], split[1]
}
return "", ""
}
type set struct {
m map[string]struct{}
}
func getEmptyStruct() struct{} {
return struct{}{}
}
func newSet(initialValues ...string) *set {
s := &set{}
s.m = make(map[string]struct{})
for _, val := range initialValues {
s.m[val] = getEmptyStruct()
}
return s
}
func (s *set) contains(value string) bool {
_, c := s.m[value]
return c
}
var (
ErrCannotSearch = errors.New("cannot search with these parameters")
ErrInvalidOutputFormat = errors.New("invalid output format")
)
type imageListResult struct {
StrValue string
Err error
}
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()
}
}
func printImageTableHeader(writer io.Writer) {
table := getNoBorderTableWriter(writer)
row := []string{"IMAGE NAME",
"TAG",
"DIGEST",
"SIZE",
}
table.Append(row)
table.Render()
}
const (
waitTimeout = 2 * time.Second
)
+307
View File
@@ -0,0 +1,307 @@
package cli
import (
"context"
"fmt"
"io"
"net/url"
"strings"
"sync"
"github.com/dustin/go-humanize"
jsoniter "github.com/json-iterator/go"
"github.com/olekukonko/tablewriter"
"gopkg.in/yaml.v2"
zotErrors "github.com/anuvu/zot/errors"
)
type ImageSearchService interface {
getAllImages(ctx context.Context, serverURL, username, password,
outputFormat string, channel chan imageListResult, wg *sync.WaitGroup)
getImageByName(ctx context.Context, serverURL, username, password, imageName, outputFormat string,
channel chan imageListResult, wg *sync.WaitGroup)
}
type searchService struct{}
func NewImageSearchService() ImageSearchService {
return searchService{}
}
func (service searchService) getImageByName(ctx context.Context, url, username, password,
imageName, outputFormat string, c chan imageListResult, wg *sync.WaitGroup) {
defer wg.Done()
p := newSmoothRateLimiter(ctx, wg, c)
wg.Add(1)
go p.startRateLimiter()
wg.Add(1)
go getImage(ctx, url, username, password, imageName, outputFormat, c, wg, p)
}
func (service searchService) getAllImages(ctx context.Context, url, username, password,
outputFormat string, c chan imageListResult, wg *sync.WaitGroup) {
defer wg.Done()
catalog := &catalogResponse{}
catalogEndPoint, err := combineServerAndEndpointURL(url, "/v2/_catalog")
if err != nil {
if isContextDone(ctx) {
return
}
c <- imageListResult{"", err}
return
}
_, err = makeGETRequest(catalogEndPoint, username, password, catalog)
if err != nil {
if isContextDone(ctx) {
return
}
c <- imageListResult{"", err}
return
}
p := newSmoothRateLimiter(ctx, wg, c)
wg.Add(1)
go p.startRateLimiter()
for _, repo := range catalog.Repositories {
wg.Add(1)
go getImage(ctx, url, username, password, repo, outputFormat, c, wg, p)
}
}
func getImage(ctx context.Context, url, username, password, imageName, outputFormat string,
c chan imageListResult, wg *sync.WaitGroup, pool *requestsPool) {
defer wg.Done()
tagListEndpoint, err := combineServerAndEndpointURL(url, fmt.Sprintf("/v2/%s/tags/list", imageName))
if err != nil {
if isContextDone(ctx) {
return
}
c <- imageListResult{"", err}
return
}
tagsList := &tagListResp{}
_, err = makeGETRequest(tagListEndpoint, username, password, &tagsList)
if err != nil {
if isContextDone(ctx) {
return
}
c <- imageListResult{"", err}
return
}
for _, tag := range tagsList.Tags {
wg.Add(1)
go addManifestCallToPool(ctx, pool, url, username, password, imageName, tag, outputFormat, c, wg)
}
}
func isContextDone(ctx context.Context) bool {
select {
case <-ctx.Done():
return true
default:
return false
}
}
func addManifestCallToPool(ctx context.Context, p *requestsPool, url, username, password, imageName,
tagName, outputFormat string, c chan imageListResult, wg *sync.WaitGroup) {
defer wg.Done()
resultManifest := manifestResponse{}
manifestEndpoint, err := combineServerAndEndpointURL(url, fmt.Sprintf("/v2/%s/manifests/%s", imageName, tagName))
if err != nil {
if isContextDone(ctx) {
return
}
c <- imageListResult{"", err}
}
job := manifestJob{
url: manifestEndpoint,
username: username,
imageName: imageName,
password: password,
tagName: tagName,
manifestResp: resultManifest,
outputFormat: outputFormat,
}
wg.Add(1)
p.submitJob(&job)
}
type tagListResp struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
type imageStruct struct {
Name string `json:"name"`
Tags []tags `json:"tags"`
}
type tags struct {
Name string `json:"name"`
Size uint64 `json:"size"`
Digest string `json:"digest"`
}
func (img imageStruct) string(format string) (string, error) {
switch strings.ToLower(format) {
case "", "text":
return img.stringPlainText()
case "json":
return img.stringJSON()
case "yml", "yaml":
return img.stringYAML()
default:
return "", ErrInvalidOutputFormat
}
}
func (img imageStruct) stringPlainText() (string, error) {
var builder strings.Builder
table := getNoBorderTableWriter(&builder)
for _, tag := range img.Tags {
imageName := ellipsize(img.Name, imageNameWidth, ellipsis)
tagName := ellipsize(tag.Name, tagWidth, ellipsis)
digest := ellipsize(tag.Digest, digestWidth, "")
size := ellipsize(strings.ReplaceAll(humanize.Bytes(tag.Size), " ", ""), sizeWidth, ellipsis)
row := []string{imageName,
tagName,
digest,
size,
}
table.Append(row)
}
table.Render()
return builder.String(), nil
}
func (img imageStruct) stringJSON() (string, error) {
var json = jsoniter.ConfigCompatibleWithStandardLibrary
body, err := json.MarshalIndent(img, "", " ")
if err != nil {
return "", err
}
return string(body), nil
}
func (img imageStruct) stringYAML() (string, error) {
body, err := yaml.Marshal(&img)
if err != nil {
return "", err
}
return string(body), nil
}
type catalogResponse struct {
Repositories []string `json:"repositories"`
}
type manifestResponse struct {
Layers []struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size uint64 `json:"size"`
} `json:"layers"`
Annotations struct {
WsTychoStackerStackerYaml string `json:"ws.tycho.stacker.stacker_yaml"`
WsTychoStackerGitVersion string `json:"ws.tycho.stacker.git_version"`
} `json:"annotations"`
Config struct {
Size int `json:"size"`
Digest string `json:"digest"`
MediaType string `json:"mediaType"`
} `json:"config"`
SchemaVersion int `json:"schemaVersion"`
}
func combineServerAndEndpointURL(serverURL, endPoint string) (string, error) {
if !isURL(serverURL) {
return "", zotErrors.ErrInvalidURL
}
newURL, err := url.Parse(serverURL)
if err != nil {
return "", zotErrors.ErrInvalidURL
}
newURL, _ = newURL.Parse(endPoint)
return newURL.String(), nil
}
func ellipsize(text string, max int, trailing string) string {
if len(text) <= max {
return text
}
chopLength := len(trailing)
return text[:max-chopLength] + trailing
}
func getNoBorderTableWriter(writer io.Writer) *tablewriter.Table {
table := tablewriter.NewWriter(writer)
table.SetAutoWrapText(false)
table.SetAutoFormatHeaders(true)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetColumnSeparator("")
table.SetRowSeparator("")
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
table.SetColMinWidth(colImageNameIndex, imageNameWidth)
table.SetColMinWidth(colTagIndex, tagWidth)
table.SetColMinWidth(colDigestIndex, digestWidth)
table.SetColMinWidth(colSizeIndex, sizeWidth)
return table
}
const (
imageNameWidth = 32
tagWidth = 24
digestWidth = 8
sizeWidth = 8
ellipsis = "..."
colImageNameIndex = 0
colTagIndex = 1
colDigestIndex = 2
colSizeIndex = 3
)