Repository: regclient/regclient
Pull request #531 (2023-09-09 18:47:04)

Support digest-tags in artifact list and tree
<!--

Commits must be signed indicating your agreement to the [DCO](https://developercertificate.org/).
See [DCO missing](https://github.com/src-d/guide/blob/master/developer-community/fix-DCO.md) for steps to fix a missing signoff.

-->

### Describe the change

This adds support for showing digest-tags (i.e. those used by sigstore) in the `regctl artifact list` and `regctl artifact tree` output.
<!-- Include the type of change: bug fix, new feature, breaking change, documentation update -->
<!-- Describe what was changed, why the change was made, and how it was implemented -->

### How to verify it

```shell
regctl artifact list --platform local --digest-tags ghcr.io/regclient/regctl:edge
regctl artifact tree --digest-tags ghcr.io/regclient/regctl:edge
```
<!-- Include steps that can be taken to verify the change -->

### Changelog text

- Support digest-tags in artifact list and tree output
<!-- If the release changelog should have an entry for this, include it here -->

### Please verify and check that the pull request fulfills the following requirements

<!-- Mark the following with an [X] to verify they are included -->

- [X] Tests have been added or not applicable
- [X] Documentation has been added, updated, or not applicable
- [X] Changes have been rebased to main
- [X] Multiple commits to the same code have been squashed



Patch:
diff --git a/cmd/regctl/artifact.go b/cmd/regctl/artifact.go
index 36ce21b..d2c0316 100644
--- a/cmd/regctl/artifact.go
+++ b/cmd/regctl/artifact.go
@@ -24,6 +24,7 @@ import (
 	v1 "github.com/regclient/regclient/types/oci/v1"
 	"github.com/regclient/regclient/types/platform"
 	"github.com/regclient/regclient/types/ref"
+	"github.com/regclient/regclient/types/referrer"
 	"github.com/spf13/cobra"
 )
 
@@ -99,6 +100,7 @@ var artifactOpts struct {
 	artifactFile     []string
 	artifactFileMT   []string
 	byDigest         bool
+	digestTags       bool
 	filterAT         string
 	filterAnnot      []string
 	formatList       string
@@ -133,6 +135,7 @@ func init() {
 	artifactGetCmd.Flags().StringVar(&artifactOpts.sortAnnot, "sort-annotation", "", "Annotation used for sorting results")
 	artifactGetCmd.Flags().BoolVar(&artifactOpts.sortDesc, "sort-desc", false, "Sort in descending order")
 
+	artifactListCmd.Flags().BoolVar(&artifactOpts.digestTags, "digest-tags", false, "Include digest tags")
 	artifactListCmd.Flags().StringVar(&artifactOpts.filterAT, "filter-artifact-type", "", "Filter descriptors by artifactType")
 	artifactListCmd.Flags().StringArrayVar(&artifactOpts.filterAnnot, "filter-annotation", []string{}, "Filter descriptors by annotation (key=value)")
 	artifactListCmd.Flags().StringVar(&artifactOpts.formatList, "format", "{{printPretty .}}", "Format output with go template syntax")
@@ -167,6 +170,7 @@ func init() {
 	artifactPutCmd.Flags().StringVar(&artifactOpts.refers, "refers", "", "EXPERIMENTAL: Set a referrer to the reference")
 	artifactPutCmd.Flags().MarkHidden("refers")
 
+	artifactTreeCmd.Flags().BoolVar(&artifactOpts.digestTags, "digest-tags", false, "Include digest tags")
 	artifactTreeCmd.Flags().StringVar(&artifactOpts.filterAT, "filter-artifact-type", "", "Filter descriptors by artifactType")
 	artifactTreeCmd.Flags().StringArrayVar(&artifactOpts.filterAnnot, "filter-annotation", []string{}, "Filter descriptors by annotation (key=value)")
 	artifactTreeCmd.Flags().StringVar(&artifactOpts.formatTree, "format", "{{printPretty .}}", "Format output with go template syntax")
@@ -460,6 +464,44 @@ func runArtifactList(cmd *cobra.Command, args []string) error {
 	if err != nil {
 		return err
 	}
+
+	// include digest tags if requested
+	if artifactOpts.digestTags {
+		tl, err := rc.TagList(ctx, rSubject)
+		if err != nil {
+			return fmt.Errorf("failed to list tags: %w", err)
+		}
+		if rl.Subject.Digest == "" {
+			mh, err := rc.ManifestHead(ctx, rl.Subject, regclient.WithManifestRequireDigest())
+			if err != nil {
+				return fmt.Errorf("failed to get manifest digest: %w", err)
+			}
+			rl.Subject.Digest = mh.GetDescriptor().Digest.String()
+		}
+		prefix, err := referrer.FallbackTag(rl.Subject)
+		if err != nil {
+			return fmt.Errorf("failed to compute fallback tag: %v", err)
+		}
+		for _, t := range tl.Tags {
+			if strings.HasPrefix(t, prefix.Tag) && !sliceHasStr(rl.Tags, t) {
+				rTag := rl.Subject
+				rTag.Tag = t
+				rTag.Digest = ""
+				mh, err := rc.ManifestHead(ctx, rTag, regclient.WithManifestRequireDigest())
+				if err != nil {
+					return fmt.Errorf("failed to query digest tag: %v", err)
+				}
+				desc := mh.GetDescriptor()
+				if desc.Annotations == nil {
+					desc.Annotations = map[string]string{}
+				}
+				desc.Annotations[types.AnnotationRefName] = t
+				rl.Descriptors = append(rl.Descriptors, desc)
+				rl.Tags = append(rl.Tags, t)
+			}
+		}
+	}
+
 	switch artifactOpts.formatList {
 	case "raw":
 		artifactOpts.formatList = "{{ range $key,$vals := .Manifest.RawHeaders}}{{range $val := $vals}}{{printf \"%s: %s\\n\" $key $val }}{{end}}{{end}}{{printf \"\\n%s\" .Manifest.RawBody}}"
@@ -807,8 +849,18 @@ func runArtifactTree(cmd *cobra.Command, args []string) error {
 		referrerOpts = append(referrerOpts, scheme.WithReferrerAnnotations(af))
 	}
 
+	// include digest tags if requested
+	tags := []string{}
+	if artifactOpts.digestTags {
+		tl, err := rc.TagList(ctx, r)
+		if err != nil {
+			return fmt.Errorf("failed to list tags: %w", err)
+		}
+		tags = tl.Tags
+	}
+
 	seen := []string{}
-	tr, err := treeAddResult(ctx, rc, r, seen, referrerOpts)
+	tr, err := treeAddResult(ctx, rc, r, seen, referrerOpts, tags)
 	var twErr error
 	if tr != nil {
 		twErr = template.Writer(cmd.OutOrStdout(), artifactOpts.formatTree, tr)
@@ -819,7 +871,7 @@ func runArtifactTree(cmd *cobra.Command, args []string) error {
 	return twErr
 }
 
-func treeAddResult(ctx context.Context, rc *regclient.RegClient, r ref.Ref, seen []string, rOpts []scheme.ReferrerOpts) (*treeResult, error) {
+func treeAddResult(ctx context.Context, rc *regclient.RegClient, r ref.Ref, seen []string, rOpts []scheme.ReferrerOpts, tags []string) (*treeResult, error) {
 	tr := treeResult{
 		Ref: r,
 	}
@@ -856,7 +908,7 @@ func treeAddResult(ctx context.Context, rc *regclient.RegClient, r ref.Ref, seen
 			rChild := r
 			rChild.Tag = ""
 			rChild.Digest = d.Digest.String()
-			tChild, err := treeAddResult(ctx, rc, rChild, seen, rOpts)
+			tChild, err := treeAddResult(ctx, rc, rChild, seen, rOpts, tags)
 			if tChild != nil {
 				tChild.ArtifactType = d.ArtifactType
 				if d.Platform != nil {
@@ -882,7 +934,7 @@ func treeAddResult(ctx context.Context, rc *regclient.RegClient, r ref.Ref, seen
 			rReferrer := r
 			rReferrer.Tag = ""
 			rReferrer.Digest = d.Digest.String()
-			tReferrer, err := treeAddResult(ctx, rc, rReferrer, seen, rOpts)
+			tReferrer, err := treeAddResult(ctx, rc, rReferrer, seen, rOpts, tags)
 			if tReferrer != nil {
 				tReferrer.ArtifactType = d.ArtifactType
 				if d.Platform != nil {
@@ -897,6 +949,30 @@ func treeAddResult(ctx context.Context, rc *regclient.RegClient, r ref.Ref, seen
 		}
 	}
 
+	// include digest tags if requested
+	if artifactOpts.digestTags {
+		prefix, err := referrer.FallbackTag(r)
+		if err != nil {
+			return &tr, fmt.Errorf("failed to compute fallback tag: %v", err)
+		}
+		for _, t := range tags {
+			if strings.HasPrefix(t, prefix.Tag) && !sliceHasStr(rl.Tags, t) {
+				rTag := r
+				rTag.Tag = t
+				rTag.Digest = ""
+				tReferrer, err := treeAddResult(ctx, rc, rTag, seen, rOpts, tags)
+				if tReferrer != nil {
+					tReferrer.Ref.Tag = t
+					tReferrer.Ref.Digest = ""
+					tr.Referrer = append(tr.Referrer, tReferrer)
+				}
+				if err != nil {
+					return &tr, err
+				}
+			}
+		}
+	}
+
 	return &tr, nil
 }
 
@@ -944,6 +1020,12 @@ func (tr *treeResult) marshalPretty(indent string) ([]byte, error) {
 			return nil, err
 		}
 	}
+	if tr.ArtifactType == "" && strings.HasPrefix(tr.Ref.Tag, "sha256-") {
+		_, err = result.WriteString(": " + tr.Ref.Tag)
+		if err != nil {
+			return nil, err
+		}
+	}
 	_, err = result.WriteString("\n")
 	if err != nil {
 		return nil, err


Test patch:
diff --git a/cmd/regctl/artifact_test.go b/cmd/regctl/artifact_test.go
index 7a18b52..b05f3ff 100644
--- a/cmd/regctl/artifact_test.go
+++ b/cmd/regctl/artifact_test.go
@@ -12,6 +12,83 @@ import (
 	"github.com/regclient/regclient/types"
 )
 
+func TestArtifactList(t *testing.T) {
+	saveArtifactOpts := artifactOpts
+	tt := []struct {
+		name        string
+		args        []string
+		expectErr   error
+		expectOut   string
+		outContains bool
+	}{
+		{
+			name:      "Missing arg",
+			args:      []string{"artifact", "list"},
+			expectErr: fmt.Errorf("accepts 1 arg(s), received 0"),
+		},
+		{
+			name:      "Invalid ref",
+			args:      []string{"artifact", "list", "invalid*ref"},
+			expectErr: types.ErrInvalidReference,
+		},
+		{
+			name:      "Missing manifest",
+			args:      []string{"artifact", "list", "ocidir://../../testdata/testrepo:missing"},
+			expectErr: types.ErrNotFound,
+		},
+		{
+			name:        "No referrers",
+			args:        []string{"artifact", "list", "ocidir://../../testdata/testrepo:v1"},
+			expectOut:   "Referrers:",
+			outContains: true,
+		},
+		{
+			name:        "Referrers",
+			args:        []string{"artifact", "list", "ocidir://../../testdata/testrepo:v2"},
+			expectOut:   "Referrers:",
+			outContains: true,
+		},
+		{
+			name:        "With Digest Tags",
+			args:        []string{"artifact", "list", "ocidir://../../testdata/testrepo:v2", "--digest-tags"},
+			expectOut:   "Referrers:",
+			outContains: true,
+		},
+		{
+			name:        "Filter",
+			args:        []string{"artifact", "list", "ocidir://../../testdata/testrepo:v2", "--filter-artifact-type", "application/example.sbom"},
+			expectOut:   "application/example.sbom",
+			outContains: true,
+		},
+		{
+			name:      "Filter and Format",
+			args:      []string{"artifact", "list", "ocidir://../../testdata/testrepo:v2", "--filter-artifact-type", "application/example.sbom", "--format", "{{ ( index .Descriptors 0 ).ArtifactType }}"},
+			expectOut: "application/example.sbom",
+		},
+	}
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			out, err := cobraTest(t, tc.args...)
+			artifactOpts = saveArtifactOpts
+			if tc.expectErr != nil {
+				if err == nil {
+					t.Errorf("did not receive expected error: %v", tc.expectErr)
+				} else if !errors.Is(err, tc.expectErr) && err.Error() != tc.expectErr.Error() {
+					t.Errorf("unexpected error, received %v, expected %v", err, tc.expectErr)
+				}
+				return
+			}
+			if err != nil {
+				t.Errorf("returned unexpected error: %v", err)
+				return
+			}
+			if (!tc.outContains && out != tc.expectOut) || (tc.outContains && !strings.Contains(out, tc.expectOut)) {
+				t.Errorf("unexpected output, expected %s, received %s", tc.expectOut, out)
+			}
+		})
+	}
+}
+
 func TestArtifactPut(t *testing.T) {
 	testDir := t.TempDir()
 	testData := []byte("hello world")
@@ -139,6 +216,12 @@ func TestArtifactTree(t *testing.T) {
 			expectOut:   "Referrers",
 			outContains: true,
 		},
+		{
+			name:        "With Digest Tags",
+			args:        []string{"artifact", "tree", "ocidir://../../testdata/testrepo:v2", "--digest-tags"},
+			expectOut:   "Referrers",
+			outContains: true,
+		},
 		{
 			name:        "Filter",
 			args:        []string{"artifact", "tree", "ocidir://../../testdata/testrepo:v2", "--filter-artifact-type", "application/example.sbom"},