diff --git a/README.md b/README.md index f3c8a485e..a90afd70b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ * Uses [OCI storage layout](https://github.com/opencontainers/image-spec/blob/master/image-layout.md) for storage layout * Supports [helm charts](https://helm.sh/docs/topics/registries/) * Currently suitable for on-prem deployments (e.g. colocated with Kubernetes) +* [Command-line client support](#cli) * TLS support * Authentication via: * TLS mutual authentication @@ -97,6 +98,44 @@ podman run --rm -p 8080:8080 \ zot:latest ``` +# CLI + +The same zot binary can be used for interacting with any zot server instances. + +## Adding a zot server URL + +To add a zot server URL with an alias "remote-zot": + +```console +$ zot config add remote-zot https://server-example:8080 +``` + +List all configured URLs with their aliases: +```console +$ zot config -l +remote-zot https://server-example:8080 +local http://localhost:8080 +``` + +## Fetching images +You can fetch all images from a server by using its alias specified [in this step](#adding-a-zot-server-url): + +```console +$ zot images remote-zot +IMAGE NAME TAG DIGEST SIZE +postgres 9.6.18-alpine ef27f3e1 14.4MB +postgres 9.5-alpine 264450a7 14.4MB +busybox latest 414aeb86 707.8KB +``` + +Or filter the list by an image name: + +```console +$ zot images remote-zot -n busybox +IMAGE NAME TAG DIGEST SIZE +busybox latest 414aeb86 707.8KB +``` + # Ecosystem Since we couldn't find clients or client libraries that are stictly compliant to diff --git a/WORKSPACE b/WORKSPACE index 43e73b955..bb74ce86b 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -461,8 +461,8 @@ go_repository( go_repository( name = "com_github_kr_pty", importpath = "github.com/kr/pty", - sum = "h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=", - version = "v1.1.8", + sum = "h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=", + version = "v1.1.1", ) go_repository( @@ -881,8 +881,8 @@ go_repository( go_repository( name = "org_golang_google_appengine", importpath = "google.golang.org/appengine", - sum = "h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs=", - version = "v1.1.0", + sum = "h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=", + version = "v1.4.0", ) go_repository( @@ -1521,3 +1521,52 @@ go_repository( sum = "h1:UhZDfRO8JRQru4/+LlLE0BRKGF8L+PICnvYZmx/fEGA=", version = "v1.24.0", ) + +go_repository( + name = "com_github_briandowns_spinner", + importpath = "github.com/briandowns/spinner", + sum = "h1:OixPqDEcX3juo5AjQZAnFPbeUA0jvkp2qzB5gOZJ/L0=", + version = "v1.11.1", +) + +go_repository( + name = "com_github_coreos_go_etcd", + importpath = "github.com/coreos/go-etcd", + sum = "h1:bXhRBIXoTm9BYHS3gE0TtQuyNZyeEMux2sDi4oo5YOo=", + version = "v2.0.0+incompatible", +) + +go_repository( + name = "com_github_dustin_go_humanize", + importpath = "github.com/dustin/go-humanize", + sum = "h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=", + version = "v1.0.0", +) + +go_repository( + name = "com_github_go_resty_resty_v2", + importpath = "github.com/go-resty/resty/v2", + sum = "h1:JOOeAvjSlapTT92p8xiS19Zxev1neGikoHsXJeOq8So=", + version = "v2.3.0", +) + +go_repository( + name = "com_github_mattn_go_runewidth", + importpath = "github.com/mattn/go-runewidth", + sum = "h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54=", + version = "v0.0.7", +) + +go_repository( + name = "com_github_olekukonko_tablewriter", + importpath = "github.com/olekukonko/tablewriter", + sum = "h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=", + version = "v0.0.4", +) + +go_repository( + name = "org_golang_x_exp", + importpath = "golang.org/x/exp", + sum = "h1:c2HOrn5iMezYjSlGPncknSEr/8x5LELb/ilJbXi9DEA=", + version = "v0.0.0-20190121172915-509febef88a4", +) diff --git a/cmd/zot/main.go b/cmd/zot/main.go index c6b2b8b82..7d51f693e 100644 --- a/cmd/zot/main.go +++ b/cmd/zot/main.go @@ -2,12 +2,20 @@ package main import ( "os" + "path" "github.com/anuvu/zot/pkg/cli" ) func main() { - if err := cli.NewRootCmd().Execute(); err != nil { + home, err := os.UserHomeDir() + if err != nil { + panic(err) + } + + configPath := path.Join(home + "/.zot") + + if err := cli.NewRootCmd(configPath).Execute(); err != nil { os.Exit(1) } } diff --git a/cmd/zot/main_test.go b/cmd/zot/main_test.go index e719828b7..df67b7712 100644 --- a/cmd/zot/main_test.go +++ b/cmd/zot/main_test.go @@ -1,6 +1,7 @@ package main_test import ( + "io/ioutil" "testing" "github.com/anuvu/zot/pkg/api" @@ -14,7 +15,8 @@ func TestIntegration(t *testing.T) { c := api.NewController(config) So(c, ShouldNotBeNil) - cl := cli.NewRootCmd() + tempFile, _ := ioutil.TempFile("", "tmp-") + cl := cli.NewRootCmd(tempFile.Name()) So(cl, ShouldNotBeNil) So(cl.Execute(), ShouldBeNil) diff --git a/errors/errors.go b/errors/errors.go index b0a300f91..4b9a9bfa7 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -3,27 +3,35 @@ package errors import "errors" var ( - ErrBadConfig = errors.New("config: invalid config") - ErrRepoNotFound = errors.New("repository: not found") - ErrRepoIsNotDir = errors.New("repository: not a directory") - ErrRepoBadVersion = errors.New("repository: unsupported layout version") - ErrManifestNotFound = errors.New("manifest: not found") - ErrBadManifest = errors.New("manifest: invalid contents") - ErrUploadNotFound = errors.New("uploads: not found") - ErrBadUploadRange = errors.New("uploads: bad range") - ErrBlobNotFound = errors.New("blob: not found") - ErrBadBlob = errors.New("blob: bad blob") - ErrBadBlobDigest = errors.New("blob: bad blob digest") - ErrUnknownCode = errors.New("error: unknown error code") - ErrBadCACert = errors.New("tls: invalid ca cert") - ErrBadUser = errors.New("ldap: non-existent user") - ErrEntriesExceeded = errors.New("ldap: too many entries returned") - ErrLDAPEmptyPassphrase = errors.New("ldap: empty passphrase") - ErrLDAPBadConn = errors.New("ldap: bad connection") - ErrLDAPConfig = errors.New("config: invalid LDAP configuration") - ErrCacheRootBucket = errors.New("cache: unable to create/update root bucket") - ErrCacheNoBucket = errors.New("cache: unable to find bucket") - ErrCacheMiss = errors.New("cache: miss") - ErrRequireCred = errors.New("ldap: bind credentials required") - ErrInvalidCred = errors.New("ldap: invalid credentials") + ErrBadConfig = errors.New("config: invalid config") + ErrRepoNotFound = errors.New("repository: not found") + ErrRepoIsNotDir = errors.New("repository: not a directory") + ErrRepoBadVersion = errors.New("repository: unsupported layout version") + ErrManifestNotFound = errors.New("manifest: not found") + ErrBadManifest = errors.New("manifest: invalid contents") + ErrUploadNotFound = errors.New("uploads: not found") + ErrBadUploadRange = errors.New("uploads: bad range") + ErrBlobNotFound = errors.New("blob: not found") + ErrBadBlob = errors.New("blob: bad blob") + ErrBadBlobDigest = errors.New("blob: bad blob digest") + ErrUnknownCode = errors.New("error: unknown error code") + ErrBadCACert = errors.New("tls: invalid ca cert") + ErrBadUser = errors.New("ldap: non-existent user") + ErrEntriesExceeded = errors.New("ldap: too many entries returned") + ErrLDAPEmptyPassphrase = errors.New("ldap: empty passphrase") + ErrLDAPBadConn = errors.New("ldap: bad connection") + ErrLDAPConfig = errors.New("config: invalid LDAP configuration") + ErrCacheRootBucket = errors.New("cache: unable to create/update root bucket") + ErrCacheNoBucket = errors.New("cache: unable to find bucket") + ErrCacheMiss = errors.New("cache: miss") + ErrRequireCred = errors.New("ldap: bind credentials required") + ErrInvalidCred = errors.New("ldap: invalid credentials") + ErrInvalidArgs = errors.New("cli: Invalid Arguments") + ErrInvalidFlagsCombination = errors.New("cli: Invalid combination of flags. Add --help flag") + ErrInvalidURL = errors.New("cli: invalid URL format") + ErrUnauthorizedAccess = errors.New("cli: unauthorized access. check credentials") + ErrCannotResetConfigKey = errors.New("cli: cannot reset given config key") + ErrConfigNotFound = errors.New("cli: config with the given name does not exist") + ErrNoURLProvided = errors.New("cli: no URL provided in argument or via config. see 'zot config -h'") + ErrIllegalConfigKey = errors.New("cli: given config key is not allowed") ) diff --git a/go.mod b/go.mod index 1bf1511ae..3dd38454f 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.14 require ( github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 + github.com/briandowns/spinner v1.11.1 github.com/chartmuseum/auth v0.4.0 + github.com/dustin/go-humanize v1.0.0 github.com/getlantern/deepcopy v0.0.0-20160317154340-7f45deb8130a github.com/go-chi/chi v4.0.2+incompatible // indirect github.com/go-ldap/ldap/v3 v3.1.3 @@ -15,6 +17,7 @@ require ( github.com/mitchellh/mapstructure v1.1.2 github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 // indirect github.com/nmcclain/ldap v0.0.0-20191021200707-3b3b69a7e9e3 + github.com/olekukonko/tablewriter v0.0.4 github.com/opencontainers/distribution-spec v1.0.0-rc0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.0.1 @@ -29,4 +32,5 @@ require ( go.etcd.io/bbolt v1.3.4 golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 gopkg.in/resty.v1 v1.12.0 + gopkg.in/yaml.v2 v2.2.4 ) diff --git a/go.sum b/go.sum index 5e12ea3e2..3207c507f 100644 --- a/go.sum +++ b/go.sum @@ -12,20 +12,18 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/apex/log v1.1.1 h1:BwhRZ0qbjYtTob0I+2M+smavV0kOC8XgcnGZcyL9liA= -github.com/apex/log v1.1.1/go.mod h1:Ls949n1HFtXfbDcjiTTFQqkVUrte0puoIBfO3SVgwOA= github.com/apex/log v1.4.0 h1:jYWeNt9kWJOf1ifht8UjsCQ00eiPnFrUzCBCiiJMw/g= github.com/apex/log v1.4.0/go.mod h1:UMNC4vQNC7hb5gyr47r18ylK1n34rV7GO+gb0wpXvcE= github.com/apex/logs v0.0.7/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo= github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE= github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= -github.com/aphistic/sweet v0.3.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.23.21/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/briandowns/spinner v1.11.1 h1:OixPqDEcX3juo5AjQZAnFPbeUA0jvkp2qzB5gOZJ/L0= +github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/chartmuseum/auth v0.4.0 h1:CkCJPO/daho9iN9t6ztK4cJRjHkQoom5/n5ndAS3OyM= @@ -43,7 +41,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSY github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.2.2 h1:jCwT2GTP+PY5nBz3c/YL5PAIbusElVrPujOBSCj8xRg= github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= @@ -55,9 +52,12 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -88,6 +88,7 @@ github.com/go-openapi/spec v0.19.0 h1:A4SZ6IWh3lnjH0rG0Z5lkxazMGBECtrZcbyYQi+64k github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= github.com/go-openapi/swag v0.17.0 h1:iqrgMg7Q7SvtbWLlltPrkMs0UBJI6oTSs79JFRUi880= github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -113,8 +114,10 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -143,28 +146,21 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.8.3 h1:CkLseiEYMM/fRb0RIg9mXB+Iwgmle+U9KGFu+JCO4Ec= -github.com/klauspost/compress v1.8.3/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.10.9 h1:pPRt1Z78crspaHISkpSSHjDlx+Tt9suHe519dsI0vF4= github.com/klauspost/compress v1.10.9/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/cpuid v1.2.1 h1:vJi+O/nMdFt0vqm8NZBI6wzALWdA2X+egi0ogNyrC/w= -github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= -github.com/klauspost/pgzip v1.2.1 h1:oIPZROsWuPHpOdMVWLuJZXwgjhrW8r1yEX8UqMyeNHM= -github.com/klauspost/pgzip v1.2.1/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.4 h1:TQ7CNpYKovDOmqzRHKxJh0BeaBI7UdQZYc6p7pMQh1A= github.com/klauspost/pgzip v1.2.4/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= @@ -172,16 +168,20 @@ github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329 h1:2gxZ0XQIU/5z3Z3bUBu+FXuk2pFbkN6tcwi/pjyaDic= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -196,26 +196,23 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 h1:D9EvfGQvlkKaDr2CRKN++7HbSXbefUNDrPq60T+g24s= github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484/go.mod h1:O1EljZ+oHprtxDDPHiMWVo/5dBT6PlvWX5PSwj80aBA= github.com/nmcclain/ldap v0.0.0-20191021200707-3b3b69a7e9e3 h1:NNis9uuNpG5h97Dvxxo53Scg02qBg+3Nfabg6zjFGu8= github.com/nmcclain/ldap v0.0.0-20191021200707-3b3b69a7e9e3/go.mod h1:YtrVB1/v9Td9SyjXpjYVmbdKgj9B0nPTBsdGUxy0i8U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= +github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/opencontainers/distribution-spec v1.0.0-rc0 h1:xMzwhweo1gjvEo74mQjGTLau0TD3ACyTEC1310NbuSQ= github.com/opencontainers/distribution-spec v1.0.0-rc0/go.mod h1:copR2flp+jTEvQIFMb6MIx45OkrxzqyjszPDT3hx/5Q= -github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= -github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/runtime-spec v1.0.1 h1:wY4pOY8fBdSIvs9+IDHC55thBuEulhzfSgKeC1yFvzQ= -github.com/opencontainers/runtime-spec v1.0.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.0.2 h1:UfAcuLBJB9Coz72x1hgl8O5RVzTdNiaglX6v2DM6FI0= github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/umoci v0.4.6 h1:nUULYM+jSLLJCVN2gd4wldm8/yuVMahC36UXna3jEqI= @@ -243,7 +240,6 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rootless-containers/proto v0.1.0 h1:gS1JOMEtk1YDYHCzBAf/url+olMJbac7MTrgSeP6zh4= github.com/rootless-containers/proto v0.1.0/go.mod h1:vgkUFZbQd0gcE/K/ZwtE4MYjZPu0UNHLXIQxhyqAFh8= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= @@ -258,21 +254,16 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5I github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v1.0.0 h1:UVQPSSmc3qtTi+zPPkCXvZX9VvW/xT/NsRvKfwY81a8= github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= -github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= -github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs= -github.com/smartystreets/gunit v1.0.4/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= @@ -290,12 +281,12 @@ github.com/spf13/viper v1.6.1 h1:VPZzIkznI1YhVMRi6vNFLHSwhnhReBfgTxIPccpfdZk= github.com/spf13/viper v1.6.1/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= @@ -308,6 +299,7 @@ github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+t github.com/swaggo/swag v1.6.3 h1:N+uVPGP4H2hXoss2pt5dctoSUPKKRInr6qcTMOm0usI= github.com/swaggo/swag v1.6.3/go.mod h1:wcc83tB4Mb2aNiL/HP4MFeQdpHUrca+Rp/DRNgWAUio= github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= +github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= @@ -319,12 +311,8 @@ github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljT github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= -github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= -github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/vbatts/go-mtree v0.4.4 h1:+CncqETnSpxBCCUhRnNQBvxhsjWXNuc+ExZsLSNaj5o= -github.com/vbatts/go-mtree v0.4.4/go.mod h1:3sazBqLG4bZYmgRTgdh9X3iKTzwBpp5CrREJDzrNSXY= github.com/vbatts/go-mtree v0.5.0 h1:dM+5XZdqH0j9CSZeerhoN/tAySdwnmevaZHO1XGW2Vc= github.com/vbatts/go-mtree v0.5.0/go.mod h1:7JbaNHyBMng+RP8C3Q4E+4Ca8JnGQA2R/MB+jb4tSOk= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= @@ -340,10 +328,7 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 h1:ULYEB3JvPRE/IfO+9uO7vKV/xzVTO7XPAwm8xbf4w2g= -golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -364,8 +349,6 @@ golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190611141213-3f473d35a33a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190912160710-24e19bdeb0f2 h1:4dVFTC832rPn4pomLSz1vA+are2+dU19w1H8OngV7nc= -golang.org/x/net v0.0.0-20190912160710-24e19bdeb0f2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200602114024-627f9648deb9 h1:pNX+40auqi2JqRfOP1akLGtYcn15TUbkhwuCO3foqqM= golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -373,7 +356,6 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -387,8 +369,6 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190610200419-93c9922d18ae h1:xiXzMMEQdQcric9hXtr1QU98MHunKK7OTtsoU6bYWs4= golang.org/x/sys v0.0.0-20190610200419-93c9922d18ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190913121621-c3b328c6e5a7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -412,9 +392,8 @@ golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190611222205-d73e1c7e250b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74 h1:4cFkmztxtMslUX2SctSl+blCyXfpzhGOy9LhKAqSMA4= golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190913181337-0240832f5c3d h1:JG5eBjADEFhCRMAdfzvIYKuT/2E9oOzCDJMqMxD5LVw= -golang.org/x/tools v0.0.0-20190913181337-0240832f5c3d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -441,6 +420,7 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= @@ -457,6 +437,7 @@ gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/cli/BUILD.bazel b/pkg/cli/BUILD.bazel index 4bc2b3a9f..330f9afce 100644 --- a/pkg/cli/BUILD.bazel +++ b/pkg/cli/BUILD.bazel @@ -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", + ], ) diff --git a/pkg/cli/client.go b/pkg/cli/client.go new file mode 100644 index 000000000..9812e7385 --- /dev/null +++ b/pkg/cli/client.go @@ -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 +} diff --git a/pkg/cli/config_cmd.go b/pkg/cli/config_cmd.go new file mode 100644 index 000000000..4a2018d37 --- /dev/null +++ b/pkg/cli/config_cmd.go @@ -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 [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 -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 --reset + return resetConfigValue(configPath, args[0], args[1]) + } + // zot config + res, err := getConfigValue(configPath, args[0], args[1]) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), res) + case threeArgs: + //zot config + 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 ", + 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 + 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") +) diff --git a/pkg/cli/config_cmd_test.go b/pkg/cli/config_cmd_test.go new file mode 100644 index 000000000..cdf851c8b --- /dev/null +++ b/pkg/cli/config_cmd_test.go @@ -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") + }) +} diff --git a/pkg/cli/image_cmd.go b/pkg/cli/image_cmd.go new file mode 100644 index 000000000..5e5bf5f13 --- /dev/null +++ b/pkg/cli/image_cmd.go @@ -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 +` +) diff --git a/pkg/cli/image_cmd_test.go b/pkg/cli/image_cmd_test.go new file mode 100644 index 000000000..5d1e88960 --- /dev/null +++ b/pkg/cli/image_cmd_test.go @@ -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() +} diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 81e0c4d8d..031619538 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -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 diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index 9e10578dc..439ebc09c 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -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) }) } diff --git a/pkg/cli/searcher.go b/pkg/cli/searcher.go new file mode 100644 index 000000000..db6dab6f1 --- /dev/null +++ b/pkg/cli/searcher.go @@ -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 +) diff --git a/pkg/cli/service.go b/pkg/cli/service.go new file mode 100644 index 000000000..d8b883d67 --- /dev/null +++ b/pkg/cli/service.go @@ -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 +)