mirror of
https://github.com/project-zot/zot.git
synced 2026-06-15 20:07:55 +08:00
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:
+27
-3
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user