Repository: regclient/regclient (go)

Issue:
[Feature] Ref Parsing & Manipulation Functionality
It is often needed to process the data embedded in a reference.  Please consider including functionality to work with references directly.  Here are a few examples:

### regctl
Parse a reference into structured data that other tools or code can use:
```
# Splitting across lines for readability, probably better to actually print on one line
$ regctl reference inspect busybox
> {
  "registry": "docker.io",
  "repository": "library/busybox",
  "tag": "latest",
  "digest": null,
  "name": "library/busybox:latest"
}

$ regctl reference inspect --verbose http://my.test:5000/a/b/c@sha512:<...val>
> {
  "scheme": "http",
  "insecure": true,
  "host": "my.test",
  "port": 5000,
  "registry": "my.test:5000",
  "repositoryParts": ["a", "b", "c"],
  "repository": "a/b/c"
  "tag": "latest"
  "digest": "sha512:<...val>",
  "algorithm": "sha512",
  "encode": "<...val>",
  "tag": null,
  "nameSuffix": "@sha512:<...val>",
  "name": "a/b/c@sha512:<...val>"
}
```

Format references to normalize or manipulate them:
```
$ regctl reference normalize busybox
> docker.io/library/busybox:latest

$ regctl reference normalize busybox --format "myregistry.test/{{ .name }}"
> myregistry.test/library/busybox:latest

$ source="myregistry.test/dev/team/service:latest"
$ format='{{ .registry }}/qa/{{ slice .repositoryParts 1 | join "/" }}{{ .nameSuffix }}'
$ target=$(regctl reference normalize "$source" --format "$format")
$ regctl image copy "$source" "$target"
```

### regsync
More Go templates for `regsync`:
```yaml
sync:
  # Simple copy
  - source: alpine
    target: "myregistry.test/{{ .source.name }}"
    type: image
  # Their prod is our dev
  - source: "vendor.test"
    target: "myregistry.test/{{ .source.repository }}:dev"
    tags:
      allow: ["prod"]
    type: registry
```

### regbot
Expanded lua `ref` for `regbot`:
```yaml
scripts:
  - name: a
    script: |
     source = reference.new("debian")
     target = reference.new("myregistry.test/customNamespace/" .. source.name)
     image.copy(source, target)
  - name: b
    script: |
      source = reference.new("debian")
      if source.digest == nil then
        -- Or something like this; unsure if this functionality exists already
        source.digest = tag.resolveDigest(source)
      end
      if source.algorithm ~= "sha512" then
        log("Not quantum resistant")
      else
        target = reference.new("myregistr.test/customNamespace" .. source.repository .. "@" .. source.digest)
        image.copy(source, target)
      done
```

Thanks 🙂

Pull request description:
Add an experimental regctl ref command
<!--

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.

-->

### Fixes issue

Fixes #760.
<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

This adds an experimental `regctl ref` command to parse image references and allow formatted output. This also cleans up the digest command a bit (using cobra for input vs os.Stdin, and templating the 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 ref nginx --format '{{.Registry}}'
```
<!-- Include steps that can be taken to verify the change -->

### Changelog text

- Add an experimental `regctl ref` command.
<!-- 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

<!-- markdownlint-disable-file MD041 -->


Patch:
diff --git a/cmd/regctl/digest.go b/cmd/regctl/digest.go
index 1d3ed0d..3a04974 100644
--- a/cmd/regctl/digest.go
+++ b/cmd/regctl/digest.go
@@ -1,9 +1,7 @@
 package main
 
 import (
-	"fmt"
 	"io"
-	"os"
 
 	// crypto libraries included for go-digest
 	_ "crypto/sha256"
@@ -11,37 +9,45 @@ import (
 
 	"github.com/opencontainers/go-digest"
 	"github.com/spf13/cobra"
+
+	"github.com/regclient/regclient/pkg/template"
 )
 
 type digestCmd struct {
 	rootOpts *rootCmd
+	format   string
 }
 
 func NewDigestCmd(rootOpts *rootCmd) *cobra.Command {
 	digestOpts := digestCmd{
 		rootOpts: rootOpts,
 	}
-	// TODO: identify a more appropriate location for this command, leave it hidden until then
+	// TODO(bmitch): consider if this should be moved out of hidden/experimental
 	var digestCmd = &cobra.Command{
 		Hidden: true,
 		Use:    "digest",
 		Short:  "compute digest on stdin",
-		Args:   cobra.RangeArgs(0, 0),
-		RunE:   digestOpts.runDigest,
+		Long: `Output the digest from content provided on stdin.
+This command is EXPERIMENTAL and could be removed in the future.`,
+		Example: `
+# compute the digest of hello world
+echo hello world | regctl digest`,
+		Args: cobra.RangeArgs(0, 0),
+		RunE: digestOpts.runDigest,
 	}
 
+	digestCmd.Flags().StringVar(&digestOpts.format, "format", "{{.String}}", "Go template to output the digest result")
+
 	return digestCmd
 }
 
 func (digestOpts *digestCmd) runDigest(cmd *cobra.Command, args []string) error {
 	digester := digest.Canonical.Digester()
 
-	_, err := io.Copy(digester.Hash(), os.Stdin)
-
+	_, err := io.Copy(digester.Hash(), cmd.InOrStdin())
 	if err != nil {
 		return err
 	}
 
-	fmt.Println(digester.Digest().String())
-	return nil
+	return template.Writer(cmd.OutOrStdout(), digestOpts.format, digester.Digest())
 }
diff --git a/cmd/regctl/ref.go b/cmd/regctl/ref.go
new file mode 100644
index 0000000..d4fbef4
--- /dev/null
+++ b/cmd/regctl/ref.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+	"fmt"
+
+	// crypto libraries included for go-digest
+	_ "crypto/sha256"
+	_ "crypto/sha512"
+
+	"github.com/spf13/cobra"
+
+	"github.com/regclient/regclient/pkg/template"
+	"github.com/regclient/regclient/types/ref"
+)
+
+type refCmd struct {
+	rootOpts *rootCmd
+	format   string
+}
+
+func NewRefCmd(rootOpts *rootCmd) *cobra.Command {
+	refOpts := refCmd{
+		rootOpts: rootOpts,
+	}
+	// TODO(bmitch): consider if this should be moved out of hidden/experimental
+	var refCmd = &cobra.Command{
+		Hidden: true,
+		Use:    "ref",
+		Short:  "parse an image ref",
+		Long: `Parse an image reference so that it may be output with formatting.
+This command is EXPERIMENTAL and could be removed in the future.`,
+		Example: `
+# extract the registry (docker.io)
+regctl ref nginx --format '{{ .Registry }}'
+`,
+		Args: cobra.ExactArgs(1),
+		RunE: refOpts.runRef,
+	}
+
+	refCmd.Flags().StringVar(&refOpts.format, "format", "{{.CommonName}}", "Format the output using a Go template")
+
+	return refCmd
+}
+
+func (refOpts *refCmd) runRef(cmd *cobra.Command, args []string) error {
+	r, err := ref.New(args[0])
+	if err != nil {
+		return fmt.Errorf("failed to parse %s: %w", args[0], err)
+	}
+
+	return template.Writer(cmd.OutOrStdout(), refOpts.format, r)
+}
diff --git a/cmd/regctl/root.go b/cmd/regctl/root.go
index 7839d48..b0f6154 100644
--- a/cmd/regctl/root.go
+++ b/cmd/regctl/root.go
@@ -111,6 +111,7 @@ regctl version --format '{{.VCSTag}}'`,
 		NewImageCmd(&rootOpts),
 		NewIndexCmd(&rootOpts),
 		NewManifestCmd(&rootOpts),
+		NewRefCmd(&rootOpts),
 		NewRegistryCmd(&rootOpts),
 		NewRepoCmd(&rootOpts),
 		NewTagCmd(&rootOpts),


Test patch:
diff --git a/cmd/regctl/ref_test.go b/cmd/regctl/ref_test.go
new file mode 100644
index 0000000..2bcd91c
--- /dev/null
+++ b/cmd/regctl/ref_test.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestRef(t *testing.T) {
+	tt := []struct {
+		name        string
+		cmd         []string
+		expectOut   string
+		outContains bool
+	}{
+		{
+			name:      "default",
+			cmd:       []string{"ref", "nginx"},
+			expectOut: "docker.io/library/nginx:latest",
+		},
+		{
+			name:      "get registry",
+			cmd:       []string{"ref", "ghcr.io/regclient/regctl:v0.3", "--format", `{{.Registry}}`},
+			expectOut: "ghcr.io",
+		},
+		{
+			name:      "get repository",
+			cmd:       []string{"ref", "ghcr.io/regclient/regctl:v0.3", "--format", `{{.Repository}}`},
+			expectOut: "regclient/regctl",
+		},
+		{
+			name:      "get tag",
+			cmd:       []string{"ref", "ghcr.io/regclient/regctl:v0.3", "--format", `{{.Tag}}`},
+			expectOut: "v0.3",
+		},
+		{
+			name:      "get digest",
+			cmd:       []string{"ref", "ghcr.io/regclient/regctl:v0.3", "--format", `{{.Digest}}`},
+			expectOut: "",
+		},
+		{
+			name:      "get ocidir path",
+			cmd:       []string{"ref", "ocidir://regclient/regctl:v0.3", "--format", `{{.Path}}`},
+			expectOut: "regclient/regctl",
+		},
+	}
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			out, err := cobraTest(t, nil, tc.cmd...)
+			if err != nil {
+				t.Fatalf("error: %v", err)
+			}
+			if (!tc.outContains && out != tc.expectOut) || (tc.outContains && !strings.Contains(out, tc.expectOut)) {
+				t.Errorf("unexpected output, expected %s, received %s", tc.expectOut, out)
+			}
+		})
+	}
+}