From c1dd7878e43dd4f7387e81c617f7dda2fb0361bf Mon Sep 17 00:00:00 2001 From: Andrei Aaron Date: Fri, 28 May 2021 19:27:17 +0300 Subject: [PATCH] Add a '--verbose' flag to the 'zot images' output - Show individual layers with size and digest under each image - Include config digest for each image See example below ``` IMAGE NAME TAG DIGEST CONFIG LAYERS SIZE test/godev 0.4.7 7d38d8ca 05b9f86e 519MB f824a027 65MB a98af0f5 52MB ba5b2bc4 163MB 58b1ca8d 228MB 67d798ee 12MB test/cdev test 2292b4ae cf6f6c77 280MB f824a027 65MB a98af0f5 52MB ba5b2bc4 163MB test/cdev 0.4.7 2292b4ae cf6f6c77 280MB f824a027 65MB a98af0f5 52MB ba5b2bc4 163MB Note the new layers and config fields will be visible in the json/yaml format regardless of the value of the verbose flag ``` --- pkg/cli/client.go | 26 +++++++++++++---- pkg/cli/cve_cmd.go | 5 +++- pkg/cli/image_cmd.go | 8 ++++-- pkg/cli/image_cmd_test.go | 33 +++++++++++++++++++--- pkg/cli/searcher.go | 27 ++++++++++++++---- pkg/cli/service.go | 59 +++++++++++++++++++++++++++++++++------ 6 files changed, 131 insertions(+), 27 deletions(-) diff --git a/pkg/cli/client.go b/pkg/cli/client.go index c223ff60..ba4dcf77 100644 --- a/pkg/cli/client.go +++ b/pkg/cli/client.go @@ -168,19 +168,35 @@ func (p *requestsPool) doJob(job *manifestJob) { digest := header.Get("docker-content-digest") digest = strings.TrimPrefix(digest, "sha256:") + configDigest := job.manifestResp.Config.Digest + configDigest = strings.TrimPrefix(configDigest, "sha256:") + var size uint64 - for _, layer := range job.manifestResp.Layers { - size += layer.Size + layers := []layer{} + + for _, entry := range job.manifestResp.Layers { + size += entry.Size + + layers = append( + layers, + layer{ + Size: entry.Size, + Digest: strings.TrimPrefix(entry.Digest, "sha256:"), + }, + ) } image := &imageStruct{} + image.verbose = *job.config.verbose image.Name = job.imageName image.Tags = []tags{ { - Name: job.tagName, - Digest: digest, - Size: size, + Name: job.tagName, + Digest: digest, + Size: size, + ConfigDigest: configDigest, + Layers: layers, }, } diff --git a/pkg/cli/cve_cmd.go b/pkg/cli/cve_cmd.go index 8381deda..8f464220 100644 --- a/pkg/cli/cve_cmd.go +++ b/pkg/cli/cve_cmd.go @@ -18,7 +18,7 @@ func NewCveCommand(searchService SearchService) *cobra.Command { var servURL, user, outputFormat string - var isSpinner, verifyTLS, fixedFlag bool + var isSpinner, verifyTLS, fixedFlag, verbose bool var cveCmd = &cobra.Command{ Use: "cve [config-name]", @@ -64,6 +64,8 @@ func NewCveCommand(searchService SearchService) *cobra.Command { spin := spinner.New(spinner.CharSets[39], spinnerDuration, spinner.WithWriter(cmd.ErrOrStderr())) spin.Prefix = fmt.Sprintf("Fetching from %s.. ", servURL) + verbose = false + searchConfig := searchConfig{ params: searchCveParams, searchService: searchService, @@ -72,6 +74,7 @@ func NewCveCommand(searchService SearchService) *cobra.Command { outputFormat: &outputFormat, fixedFlag: &fixedFlag, verifyTLS: &verifyTLS, + verbose: &verbose, resultWriter: cmd.OutOrStdout(), spinner: spinnerState{spin, isSpinner}, } diff --git a/pkg/cli/image_cmd.go b/pkg/cli/image_cmd.go index dead9746..92a6c328 100644 --- a/pkg/cli/image_cmd.go +++ b/pkg/cli/image_cmd.go @@ -18,7 +18,7 @@ func NewImageCommand(searchService SearchService) *cobra.Command { var servURL, user, outputFormat string - var isSpinner, verifyTLS bool + var isSpinner, verifyTLS, verbose bool var imageCmd = &cobra.Command{ Use: "images [config-name]", @@ -70,6 +70,7 @@ func NewImageCommand(searchService SearchService) *cobra.Command { servURL: &servURL, user: &user, outputFormat: &outputFormat, + verbose: &verbose, spinner: spinnerState{spin, isSpinner}, verifyTLS: &verifyTLS, resultWriter: cmd.OutOrStdout(), @@ -86,7 +87,7 @@ func NewImageCommand(searchService SearchService) *cobra.Command { }, } - setupImageFlags(imageCmd, searchImageParams, &servURL, &user, &outputFormat) + setupImageFlags(imageCmd, searchImageParams, &servURL, &user, &outputFormat, &verbose) imageCmd.SetUsageTemplate(imageCmd.UsageTemplate() + usageFooter) return imageCmd @@ -107,7 +108,7 @@ func parseBooleanConfig(configPath, configName, configParam string) (bool, error } func setupImageFlags(imageCmd *cobra.Command, searchImageParams map[string]*string, - servURL, user, outputFormat *string) { + servURL, user, outputFormat *string, verbose *bool) { searchImageParams["imageName"] = imageCmd.Flags().StringP("name", "n", "", "List image details by name") searchImageParams["digest"] = imageCmd.Flags().StringP("digest", "d", "", "List images containing a specific manifest, config, or layer digest") @@ -115,6 +116,7 @@ func setupImageFlags(imageCmd *cobra.Command, searchImageParams map[string]*stri 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]") + imageCmd.Flags().BoolVar(verbose, "verbose", false, "Show verbose output") } func searchImage(searchConfig searchConfig) error { diff --git a/pkg/cli/image_cmd_test.go b/pkg/cli/image_cmd_test.go index b8d9d2c2..98c2d66d 100644 --- a/pkg/cli/image_cmd_test.go +++ b/pkg/cli/image_cmd_test.go @@ -221,7 +221,7 @@ func TestOutputFormat(t *testing.T) { space := regexp.MustCompile(`\s+`) str := space.ReplaceAllString(buff.String(), " ") So(strings.TrimSpace(str), ShouldEqual, `{ "name": "dummyImageName", "tags": [ { "name":`+ - ` "tag", "size": 123445, "digest": "DigestsAreReallyLong" } ] }`) + ` "tag", "size": 123445, "digest": "DigestsAreReallyLong", "configDigest": "", "layerDigests": null } ] }`) So(err, ShouldBeNil) }) @@ -240,7 +240,7 @@ func TestOutputFormat(t *testing.T) { space := regexp.MustCompile(`\s+`) str := space.ReplaceAllString(buff.String(), " ") So(strings.TrimSpace(str), ShouldEqual, `name: dummyImageName tags: -`+ - ` name: tag size: 123445 digest: DigestsAreReallyLong`) + ` name: tag size: 123445 digest: DigestsAreReallyLong configdigest: "" layers: []`) So(err, ShouldBeNil) Convey("Test yml", func() { @@ -257,8 +257,8 @@ func TestOutputFormat(t *testing.T) { 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(strings.TrimSpace(str), ShouldEqual, `name: dummyImageName tags: -`+ + ` name: tag size: 123445 digest: DigestsAreReallyLong configdigest: "" layers: []`) So(err, ShouldBeNil) }) }) @@ -338,6 +338,31 @@ func TestServerResponse(t *testing.T) { So(actual, ShouldContainSubstring, "repo7 test:1.0 a0ca253b 15B") }) + Convey("Test all images verbose", func() { + args := []string{"imagetest", "--verbose"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"imagetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cmd := NewImageCommand(new(searchService)) + 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) + // Actual cli output should be something similar to (order of images may differ): + // IMAGE NAME TAG DIGEST CONFIG LAYERS SIZE + // repo7 test:2.0 a0ca253b b8781e88 15B + // b8781e88 15B + // repo7 test:1.0 a0ca253b b8781e88 15B + // b8781e88 15B + So(actual, ShouldContainSubstring, "IMAGE NAME TAG DIGEST CONFIG LAYERS SIZE") + So(actual, ShouldContainSubstring, "repo7 test:2.0 a0ca253b b8781e88 15B b8781e88 15B") + So(actual, ShouldContainSubstring, "repo7 test:1.0 a0ca253b b8781e88 15B b8781e88 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)) diff --git a/pkg/cli/searcher.go b/pkg/cli/searcher.go index f23e1811..f3f260c8 100644 --- a/pkg/cli/searcher.go +++ b/pkg/cli/searcher.go @@ -60,6 +60,7 @@ type searchConfig struct { outputFormat *string verifyTLS *bool fixedFlag *bool + verbose *bool resultWriter io.Writer spinner spinnerState } @@ -323,7 +324,7 @@ func collectResults(config searchConfig, wg *sync.WaitGroup, imageErr chan strin if !foundResult && (*config.outputFormat == defaultOutoutFormat || *config.outputFormat == "") { var builder strings.Builder - printHeader(&builder) + printHeader(&builder, *config.verbose) fmt.Fprint(config.resultWriter, builder.String()) } @@ -417,22 +418,38 @@ type stringResult struct { Err error } -type printHeader func(writer io.Writer) +type printHeader func(writer io.Writer, verbose bool) -func printImageTableHeader(writer io.Writer) { +func printImageTableHeader(writer io.Writer, verbose bool) { table := getImageTableWriter(writer) - row := make([]string, 4) + + table.SetColMinWidth(colImageNameIndex, imageNameWidth) + table.SetColMinWidth(colTagIndex, tagWidth) + table.SetColMinWidth(colDigestIndex, digestWidth) + table.SetColMinWidth(colSizeIndex, sizeWidth) + + if verbose { + table.SetColMinWidth(colConfigIndex, configWidth) + table.SetColMinWidth(colLayersIndex, layersWidth) + } + + row := make([]string, 6) row[colImageNameIndex] = "IMAGE NAME" row[colTagIndex] = "TAG" row[colDigestIndex] = "DIGEST" row[colSizeIndex] = "SIZE" + if verbose { + row[colConfigIndex] = "CONFIG" + row[colLayersIndex] = "LAYERS" + } + table.Append(row) table.Render() } -func printCVETableHeader(writer io.Writer) { +func printCVETableHeader(writer io.Writer, verbose bool) { table := getCVETableWriter(writer) row := make([]string, 3) row[colCVEIDIndex] = "ID" diff --git a/pkg/cli/service.go b/pkg/cli/service.go index d7106bb2..c0ce47da 100644 --- a/pkg/cli/service.go +++ b/pkg/cli/service.go @@ -606,11 +606,20 @@ type tagListResp struct { } type imageStruct struct { - Name string `json:"name"` - Tags []tags `json:"tags"` + Name string `json:"name"` + Tags []tags `json:"tags"` + verbose bool } + type tags struct { - Name string `json:"name"` + Name string `json:"name"` + Size uint64 `json:"size"` + Digest string `json:"digest"` + ConfigDigest string `json:"configDigest"` + Layers []layer `json:"layerDigests"` +} + +type layer struct { Size uint64 `json:"size"` Digest string `json:"digest"` } @@ -632,20 +641,52 @@ func (img imageStruct) stringPlainText() (string, error) { var builder strings.Builder table := getImageTableWriter(&builder) + table.SetColMinWidth(colImageNameIndex, imageNameWidth) + table.SetColMinWidth(colTagIndex, tagWidth) + table.SetColMinWidth(colDigestIndex, digestWidth) + table.SetColMinWidth(colSizeIndex, sizeWidth) + + if img.verbose { + table.SetColMinWidth(colConfigIndex, configWidth) + table.SetColMinWidth(colLayersIndex, layersWidth) + } 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 := make([]string, 4) + config := ellipsize(tag.ConfigDigest, configWidth, "") + row := make([]string, 6) row[colImageNameIndex] = imageName row[colTagIndex] = tagName row[colDigestIndex] = digest row[colSizeIndex] = size + if img.verbose { + row[colConfigIndex] = config + row[colLayersIndex] = "" + } + table.Append(row) + + if img.verbose { + for _, entry := range tag.Layers { + layerSize := ellipsize(strings.ReplaceAll(humanize.Bytes(entry.Size), " ", ""), sizeWidth, ellipsis) + layerDigest := ellipsize(entry.Digest, digestWidth, "") + + layerRow := make([]string, 6) + layerRow[colImageNameIndex] = "" + layerRow[colTagIndex] = "" + layerRow[colDigestIndex] = "" + layerRow[colSizeIndex] = layerSize + layerRow[colConfigIndex] = "" + layerRow[colLayersIndex] = layerDigest + + table.Append(layerRow) + } + } } table.Render() @@ -737,10 +778,6 @@ func getImageTableWriter(writer io.Writer) *tablewriter.Table { 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 } @@ -771,12 +808,16 @@ const ( tagWidth = 24 digestWidth = 8 sizeWidth = 8 + configWidth = 8 + layersWidth = 8 ellipsis = "..." colImageNameIndex = 0 colTagIndex = 1 colDigestIndex = 2 - colSizeIndex = 3 + colConfigIndex = 3 + colLayersIndex = 4 + colSizeIndex = 5 cveIDWidth = 16 cveSeverityWidth = 8