From 54405b182b033815d52dcdc1213ba5c67a659b0d Mon Sep 17 00:00:00 2001
From: Angus Lees <gus@inodes.org>
Date: Fri, 23 Jun 2017 17:24:48 +1000
Subject: [PATCH] Add new `resolveImage` native function

Add a new native function that resolves docker image names into more
specific forms.  In particular, it can look up a docker registry and
convert image:tag to image@digest at jsonnet-eval time.

Limitations: Does not currently support private docker
registries (that require authentication).

Controlled via two new command line flags:
- `--resolve-images` Change implementation of resolveImage native
  function. One of: noop, registry (default "noop")
- `--resolve-images-error` Action when resolveImage fails. One of
  ignore,warn,error (default "warn")

Note in particular that the defaults will *not* do remote registry
lookups, and will only add an explicit ":latest" tag where no tag was
given.

Fixes #13
---
 cmd/root.go               |  69 ++++++++++++-
 lib/kubecfg.libsonnet     |   5 +
 lib/kubecfg_test.jsonnet  |   3 +
 utils/nativefuncs.go      |  20 +++-
 utils/nativefuncs_test.go |   4 +-
 utils/registry.go         | 211 ++++++++++++++++++++++++++++++++++++++
 utils/registry_test.go    |  90 ++++++++++++++++
 utils/resolver.go         | 146 ++++++++++++++++++++++++++
 utils/resolver_test.go    | 149 +++++++++++++++++++++++++++
 9 files changed, 691 insertions(+), 6 deletions(-)
 create mode 100644 utils/registry.go
 create mode 100644 utils/registry_test.go
 create mode 100644 utils/resolver.go
 create mode 100644 utils/resolver_test.go

diff --git a/cmd/root.go b/cmd/root.go
index 9be10471..00a4c539 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	goflag "flag"
 	"fmt"
+	"net/http"
 	"os"
 	"path/filepath"
 	"strings"
@@ -22,8 +23,10 @@ import (
 )
 
 const (
-	flagJpath  = "jpath"
-	flagExtVar = "extVar"
+	flagJpath      = "jpath"
+	flagExtVar     = "extVar"
+	flagResolver   = "resolve-images"
+	flagResolvFail = "resolve-images-error"
 )
 
 var clientConfig clientcmd.ClientConfig
@@ -31,6 +34,8 @@ var clientConfig clientcmd.ClientConfig
 func init() {
 	RootCmd.PersistentFlags().StringP(flagJpath, "J", "", "Additional jsonnet library search path")
 	RootCmd.PersistentFlags().StringSliceP(flagExtVar, "V", nil, "Values of external variables")
+	RootCmd.PersistentFlags().String(flagResolver, "noop", "Change implementation of resolveImage native function. One of: noop, registry")
+	RootCmd.PersistentFlags().String(flagResolvFail, "warn", "Action when resolveImage fails. One of ignore,warn,error")
 
 	// The "usual" clientcmd/kubectl flags
 	loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
@@ -91,11 +96,69 @@ func JsonnetVM(cmd *cobra.Command) (*jsonnet.VM, error) {
 		vm.ExtVar(kv[0], kv[1])
 	}
 
-	utils.RegisterNativeFuncs(vm)
+	resolver, err := buildResolver(cmd)
+	if err != nil {
+		return nil, err
+	}
+	utils.RegisterNativeFuncs(vm, resolver)
 
 	return vm, nil
 }
 
+func buildResolver(cmd *cobra.Command) (utils.Resolver, error) {
+	flags := cmd.Flags()
+	resolver, err := flags.GetString(flagResolver)
+	if err != nil {
+		return nil, err
+	}
+	failAction, err := flags.GetString(flagResolvFail)
+	if err != nil {
+		return nil, err
+	}
+
+	ret := resolverErrorWrapper{}
+
+	switch failAction {
+	case "ignore":
+		ret.OnErr = func(error) error { return nil }
+	case "warn":
+		ret.OnErr = func(err error) error {
+			glog.Warning(err.Error())
+			return nil
+		}
+	case "error":
+		ret.OnErr = func(err error) error { return err }
+	default:
+		return nil, fmt.Errorf("Bad value for --%s: %s", flagResolvFail, failAction)
+	}
+
+	switch resolver {
+	case "noop":
+		ret.Inner = utils.NewIdentityResolver()
+	case "registry":
+		ret.Inner = utils.NewRegistryResolver(&http.Client{
+			Transport: utils.NewAuthTransport(http.DefaultTransport),
+		})
+	default:
+		return nil, fmt.Errorf("Bad value for --%s: %s", flagResolver, resolver)
+	}
+
+	return &ret, nil
+}
+
+type resolverErrorWrapper struct {
+	Inner utils.Resolver
+	OnErr func(error) error
+}
+
+func (r *resolverErrorWrapper) Resolve(image *utils.ImageName) error {
+	err := r.Inner.Resolve(image)
+	if err != nil {
+		err = r.OnErr(err)
+	}
+	return err
+}
+
 func readObjs(cmd *cobra.Command, paths []string) ([]*runtime.Unstructured, error) {
 	vm, err := JsonnetVM(cmd)
 	if err != nil {
diff --git a/lib/kubecfg.libsonnet b/lib/kubecfg.libsonnet
index a6e271b4..90111d1e 100644
--- a/lib/kubecfg.libsonnet
+++ b/lib/kubecfg.libsonnet
@@ -8,4 +8,9 @@
   // YAML document will still be returned as an array with one
   // element.
   parseYaml:: std.native("parseYaml"),
+
+  // resolveImage(image): convert the docker image string from
+  // image:tag into a more specific image@digest, depending on kubecfg
+  // command line flags.
+  resolveImage:: std.native("resolveImage")
 }
diff --git a/lib/kubecfg_test.jsonnet b/lib/kubecfg_test.jsonnet
index 8184ee4b..e6aa3f2c 100644
--- a/lib/kubecfg_test.jsonnet
+++ b/lib/kubecfg_test.jsonnet
@@ -12,6 +12,9 @@ baz: xyzzy
 ");
 assert x == [[3, 4], {foo: "bar", baz: "xyzzy"}] : "got " + x;
 
+local i = kubecfg.resolveImage("busybox");
+assert i == "busybox:latest" : "got " + i;
+
 // Kubecfg wants to see something that looks like a k8s object
 {
   apiVersion: "test",
diff --git a/utils/nativefuncs.go b/utils/nativefuncs.go
index a6b4731e..749eb12a 100644
--- a/utils/nativefuncs.go
+++ b/utils/nativefuncs.go
@@ -9,7 +9,21 @@ import (
 	"k8s.io/client-go/pkg/util/yaml"
 )
 
-func RegisterNativeFuncs(vm *jsonnet.VM) {
+func resolveImage(resolver Resolver, image string) (string, error) {
+	n, err := ParseImageName(image)
+	if err != nil {
+		return "", err
+	}
+
+	if err := resolver.Resolve(&n); err != nil {
+		return "", err
+	}
+
+	return n.String(), nil
+}
+
+// RegisterNativeFuncs adds kubecfg's native jsonnet functions to provided VM
+func RegisterNativeFuncs(vm *jsonnet.VM, resolver Resolver) {
 	vm.NativeCallback("parseJson", []string{"json"}, func(data []byte) (res interface{}, err error) {
 		err = json.Unmarshal(data, &res)
 		return
@@ -30,4 +44,8 @@ func RegisterNativeFuncs(vm *jsonnet.VM) {
 		}
 		return ret, nil
 	})
+
+	vm.NativeCallback("resolveImage", []string{"image"}, func(image string) (string, error) {
+		return resolveImage(resolver, image)
+	})
 }
diff --git a/utils/nativefuncs_test.go b/utils/nativefuncs_test.go
index 6bcbc771..891e656b 100644
--- a/utils/nativefuncs_test.go
+++ b/utils/nativefuncs_test.go
@@ -18,7 +18,7 @@ func check(t *testing.T, err error, actual, expected string) {
 func TestParseJson(t *testing.T) {
 	vm := jsonnet.Make()
 	defer vm.Destroy()
-	RegisterNativeFuncs(vm)
+	RegisterNativeFuncs(vm, NewIdentityResolver())
 
 	_, err := vm.EvaluateSnippet("failtest", `std.native("parseJson")("barf{")`)
 	if err == nil {
@@ -37,7 +37,7 @@ func TestParseJson(t *testing.T) {
 func TestParseYaml(t *testing.T) {
 	vm := jsonnet.Make()
 	defer vm.Destroy()
-	RegisterNativeFuncs(vm)
+	RegisterNativeFuncs(vm, NewIdentityResolver())
 
 	_, err := vm.EvaluateSnippet("failtest", `std.native("parseYaml")("[barf")`)
 	if err == nil {
diff --git a/utils/registry.go b/utils/registry.go
new file mode 100644
index 00000000..23a163c0
--- /dev/null
+++ b/utils/registry.go
@@ -0,0 +1,211 @@
+package utils
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"regexp"
+	"strings"
+
+	"github.com/golang/glog"
+)
+
+var (
+	commaRegexp = regexp.MustCompile(", *")
+)
+
+// Registry is a *crazy limited* Docker registry client.
+type Registry struct {
+	URL    string
+	Client *http.Client
+}
+
+// NewRegistryClient creates a new Registry client using the given
+// http client and base URL.
+func NewRegistryClient(client *http.Client, url string) *Registry {
+	return &Registry{
+		URL:    strings.TrimSuffix(url, "/"),
+		Client: client,
+	}
+}
+
+// ManifestDigest fetches the manifest digest for a given reponame and tag.
+func (r *Registry) ManifestDigest(reponame, tag string) (string, error) {
+	url := fmt.Sprintf("%s/v2/%s/manifests/%s", r.URL, reponame, tag)
+
+	glog.V(1).Infof("HEAD %s", url)
+
+	req, err := http.NewRequest(http.MethodHead, url, nil)
+	if err != nil {
+		return "", err
+	}
+	req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json")
+	resp, err := r.Client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("Request failed: %s", resp.Status)
+	}
+
+	digest := resp.Header.Get("Docker-Content-Digest")
+	if digest == "" {
+		return "", errors.New("No digest in response")
+	}
+
+	glog.V(1).Infof("Found digest %s", digest)
+	return digest, nil
+}
+
+// stolen from golang 1.8
+func stripPort(hostport string) string {
+	colon := strings.IndexByte(hostport, ':')
+	if colon == -1 {
+		return hostport
+	}
+	if i := strings.IndexByte(hostport, ']'); i != -1 {
+		return strings.TrimPrefix(hostport[:i], "[")
+	}
+	return hostport[:colon]
+}
+
+func matchesDomain(url *url.URL, domain string) bool {
+	host := stripPort(url.Host)
+	return strings.HasSuffix(host, domain)
+}
+
+type authChallenge struct {
+	Scheme string
+	Params map[string]string
+}
+
+func parseAuthHeader(header http.Header) []*authChallenge {
+	authHeaders := header[http.CanonicalHeaderKey("WWW-Authenticate")]
+	ret := make([]*authChallenge, 0, len(authHeaders))
+	for _, h := range authHeaders {
+		var scheme string
+		params := map[string]string{}
+		parts := strings.SplitN(h, " ", 2)
+		if len(parts) < 1 || parts[0] == "" {
+			continue
+		}
+		scheme = strings.ToLower(parts[0])
+		if len(parts) == 2 {
+			args := commaRegexp.Split(parts[1], -1)
+			for _, arg := range args {
+				if parts := strings.SplitN(arg, "=", 2); len(parts) == 2 {
+
+					params[parts[0]] = strings.Trim(parts[1], `"`)
+				} else if len(parts) == 1 {
+					params[parts[0]] = ""
+				}
+			}
+
+		}
+		auth := authChallenge{
+			Scheme: scheme,
+			Params: params,
+		}
+		ret = append(ret, &auth)
+	}
+	return ret
+}
+
+// NewAuthTransport returns a roundtripper that does bearer/etc authentication
+func NewAuthTransport(inner http.RoundTripper) http.RoundTripper {
+	return &authTransport{
+		Transport:  inner,
+		Client:     &http.Client{Transport: inner},
+		tokenCache: map[string]string{},
+	}
+}
+
+type authTransport struct {
+	Client     *http.Client
+	Transport  http.RoundTripper
+	tokenCache map[string]string
+	HostDomain string
+	Username   string
+	Password   string
+}
+
+// RoundTrip is required for the http.RoundTripper interface
+func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	glog.V(1).Infof("=> %v", req)
+	resp, err := t.Transport.RoundTrip(req)
+	glog.V(1).Infof("<= err=%v resp=%v", err, resp)
+	if err == nil && resp.StatusCode == http.StatusUnauthorized && matchesDomain(req.URL, t.HostDomain) {
+		schemes := parseAuthHeader(resp.Header)
+		for _, scheme := range schemes {
+			if scheme.Scheme == "basic" {
+				glog.V(2).Infof("Retrying with basic auth")
+				req.SetBasicAuth(t.Username, t.Password)
+				glog.V(1).Infof("=> %v", req)
+				return t.Transport.RoundTrip(req)
+			}
+			if scheme.Scheme == "bearer" {
+				token, err := t.bearerAuth(scheme.Params["realm"], scheme.Params["service"], scheme.Params["scope"])
+				if err != nil {
+					return resp, err
+				}
+				glog.V(2).Infof("Retrying with bearer auth")
+				req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+				glog.V(1).Infof("=> %v", req)
+				return t.Transport.RoundTrip(req)
+			}
+		}
+		// No recognised auth schemes, return 401 failure
+	}
+
+	return resp, err
+}
+
+func (t *authTransport) bearerAuth(realm, service, scope string) (string, error) {
+	cacheKey := fmt.Sprintf("%s!%s!%s", realm, service, scope)
+	if token := t.tokenCache[cacheKey]; token != "" {
+		return token, nil
+	}
+
+	url, err := url.Parse(realm)
+	if err != nil {
+		return "", err
+	}
+
+	q := url.Query()
+	q.Set("service", service)
+	if scope != "" {
+		q.Set("scope", scope)
+	}
+	url.RawQuery = q.Encode()
+
+	req, err := http.NewRequest(http.MethodGet, url.String(), nil)
+	if t.Username != "" || t.Password != "" {
+		req.SetBasicAuth(t.Username, t.Password)
+	}
+
+	glog.V(3).Infof("Performing oauth request to %s", req.URL)
+	resp, err := t.Client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("Auth request returned %s", resp.Status)
+	}
+
+	type authToken struct {
+		Token string `json:"token"`
+	}
+	var token authToken
+	if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
+		return "", err
+	}
+	glog.V(4).Infof("Got oauth token %q", token.Token)
+	t.tokenCache[cacheKey] = token.Token
+	return token.Token, err
+}
diff --git a/utils/registry_test.go b/utils/registry_test.go
new file mode 100644
index 00000000..b1ad3a31
--- /dev/null
+++ b/utils/registry_test.go
@@ -0,0 +1,90 @@
+package utils
+
+import (
+	"net/http"
+	"net/url"
+	"reflect"
+	"testing"
+)
+
+var _ http.RoundTripper = &authTransport{}
+
+func TestStripPort(t *testing.T) {
+	cases := []struct {
+		input  string
+		output string
+	}{
+		{input: "foo:80", output: "foo"},
+		{input: "foo", output: "foo"},
+		{input: "[ip:v6]:80", output: "ip:v6"},
+		{input: "[ip:v6]", output: "ip:v6"},
+	}
+	for _, c := range cases {
+		if x := stripPort(c.input); x != c.output {
+			t.Errorf("Got %q from %q, expected %q", x, c.input, c.output)
+		}
+	}
+}
+
+func TestMatchesDomain(t *testing.T) {
+	cases := []struct {
+		url    string
+		domain string
+		result bool
+	}{
+		{url: "http://foo.bar.baz:80", domain: "baz", result: true},
+		{url: "http://foo.bar.baz:80", domain: "com", result: false},
+		{url: "http://foo.bar.baz:80", domain: "bar.baz", result: true},
+		{url: "http://foo.bar.baz:80", domain: "bar.com", result: false},
+		{url: "http://foo.bar.baz:80", domain: "foo.bar.baz", result: true},
+	}
+	for _, c := range cases {
+		url, err := url.Parse(c.url)
+		if err != nil {
+			t.Fatalf("Failed to parse url %s: %s", c.url, err)
+		}
+		if x := matchesDomain(url, c.domain); x != c.result {
+			t.Errorf("Wrong result: matchesDomain(%s, %s) => %v", url, c.domain, x)
+		}
+	}
+}
+
+func TestParseAuthHeader(t *testing.T) {
+	h := http.Header{}
+	h.Add("WWW-Authenticate", `Basic`)
+	h.Add("WWW-Authenticate", `Basic realm="User Visible Realm"`)
+	h.Add("WWW-Authenticate", `Bearer realm="https://auth.docker.io/token",service="registry.docker.io"`)
+	h.Add("WWW-Authenticate", ``)
+
+	expected := []*authChallenge{
+		&authChallenge{
+			Scheme: "basic",
+			Params: map[string]string{},
+		},
+		&authChallenge{
+			Scheme: "basic",
+			Params: map[string]string{
+				"realm": "User Visible Realm",
+			},
+		},
+		&authChallenge{
+			Scheme: "bearer",
+			Params: map[string]string{
+				"realm":   "https://auth.docker.io/token",
+				"service": "registry.docker.io",
+			},
+		},
+	}
+	auths := parseAuthHeader(h)
+	if len(auths) != len(expected) {
+		t.Errorf("Unexpected number of results: %d != %d", len(auths), len(expected))
+	}
+	for i := range auths {
+		if expected[i].Scheme != auths[i].Scheme {
+			t.Errorf("%d: Unexpected scheme: %q", i, auths[i].Scheme)
+		}
+		if !reflect.DeepEqual(expected[i].Params, auths[i].Params) {
+			t.Errorf("%d: Unexpected params: %v", i, auths[i].Params)
+		}
+	}
+}
diff --git a/utils/resolver.go b/utils/resolver.go
new file mode 100644
index 00000000..93531851
--- /dev/null
+++ b/utils/resolver.go
@@ -0,0 +1,146 @@
+package utils
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"strings"
+)
+
+const defaultRegistry = "registry-1.docker.io"
+
+// ImageName represents the parts of a docker image name
+type ImageName struct {
+	// eg: "myregistryhost:5000/fedora/httpd:version1.0"
+	Registry   string // "myregistryhost:5000"
+	Repository string // "fedora"
+	Name       string // "httpd"
+	Tag        string // "version1.0"
+	Digest     string
+}
+
+// String implements the Stringer interface
+func (n ImageName) String() string {
+	buf := bytes.Buffer{}
+	if n.Registry != "" {
+		buf.WriteString(n.Registry)
+		buf.WriteString("/")
+	}
+	if n.Repository != "" {
+		buf.WriteString(n.Repository)
+		buf.WriteString("/")
+	}
+	buf.WriteString(n.Name)
+	if n.Digest != "" {
+		buf.WriteString("@")
+		buf.WriteString(n.Digest)
+	} else {
+		buf.WriteString(":")
+		buf.WriteString(n.Tag)
+	}
+	return buf.String()
+}
+
+// RegistryRepoName returns the "repository" as used in the registry URL
+func (n ImageName) RegistryRepoName() string {
+	repo := n.Repository
+	if repo == "" {
+		repo = "library"
+	}
+	return fmt.Sprintf("%s/%s", repo, n.Name)
+}
+
+// RegistryURL returns the deduced base URL of the registry for this image
+func (n ImageName) RegistryURL() string {
+	reg := n.Registry
+	if reg == "" {
+		reg = defaultRegistry
+	}
+	return fmt.Sprintf("https://%s", reg)
+}
+
+// ParseImageName parses a docker image into an ImageName struct
+func ParseImageName(image string) (ImageName, error) {
+	ret := ImageName{}
+
+	if parts := strings.Split(image, "/"); len(parts) == 1 {
+		ret.Name = parts[0]
+	} else if len(parts) == 2 {
+		ret.Repository = parts[0]
+		ret.Name = parts[1]
+	} else if len(parts) == 3 {
+		ret.Registry = parts[0]
+		ret.Repository = parts[1]
+		ret.Name = parts[2]
+	} else {
+		return ret, fmt.Errorf("Malformed docker image name: %s", image)
+	}
+
+	if parts := strings.Split(ret.Name, "@"); len(parts) == 2 {
+		ret.Name = parts[0]
+		ret.Digest = parts[1]
+	} else if parts := strings.Split(ret.Name, ":"); len(parts) == 2 {
+		ret.Name = parts[0]
+		ret.Tag = parts[1]
+	} else if len(parts) == 1 {
+		ret.Name = parts[0]
+		ret.Tag = "latest"
+	} else {
+		return ret, fmt.Errorf("Malformed docker image name/tag: %s", image)
+	}
+
+	return ret, nil
+}
+
+// Resolver is able to resolve docker image names into more specific forms
+type Resolver interface {
+	Resolve(image *ImageName) error
+}
+
+// NewIdentityResolver returns a resolver that does only trivial
+// :latest canonicalisation
+func NewIdentityResolver() Resolver {
+	return identityResolver{}
+}
+
+type identityResolver struct{}
+
+func (r identityResolver) Resolve(image *ImageName) error {
+	return nil
+}
+
+// NewRegistryResolver returns a resolver that looks up a docker
+// registry to resolve digests
+func NewRegistryResolver(httpClient *http.Client) Resolver {
+	return &registryResolver{
+		Client: httpClient,
+		cache:  make(map[string]string),
+	}
+}
+
+type registryResolver struct {
+	Client *http.Client
+	cache  map[string]string
+}
+
+func (r *registryResolver) Resolve(n *ImageName) error {
+	if n.Digest != "" {
+		// Already has explicit digest
+		return nil
+	}
+
+	if digest, ok := r.cache[n.String()]; ok {
+		n.Digest = digest
+		return nil
+	}
+
+	c := NewRegistryClient(r.Client, n.RegistryURL())
+	digest, err := c.ManifestDigest(n.RegistryRepoName(), n.Tag)
+	if err != nil {
+		return fmt.Errorf("Unable to fetch digest for %s: %v", n, err)
+	}
+
+	r.cache[n.String()] = digest
+	n.Digest = digest
+	return nil
+}
diff --git a/utils/resolver_test.go b/utils/resolver_test.go
new file mode 100644
index 00000000..cde621d6
--- /dev/null
+++ b/utils/resolver_test.go
@@ -0,0 +1,149 @@
+package utils
+
+import (
+	"crypto/tls"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+)
+
+func TestParseImageName(t *testing.T) {
+	// Local image
+	n, err := ParseImageName("foo")
+	if err != nil {
+		t.Errorf("failed to parse local image name: %v", err)
+	}
+	if n.Registry != "" {
+		t.Errorf("Incorrect local registry: %v", n.Registry)
+	}
+	if n.Repository != "" {
+		t.Errorf("Incorrect local repository: %v", n.Repository)
+	}
+	if n.Name != "foo" {
+		t.Errorf("Incorrect local name: %v", n.Name)
+	}
+	if n.Tag != "latest" {
+		t.Errorf("Incorrect local tag: %v", n.Tag)
+	}
+	if n.String() != "foo:latest" {
+		t.Errorf("Incorrect local image string: %v", n.String())
+	}
+
+	// Full non-dockerhub image
+	n, err = ParseImageName("myregistryhost:5000/fedora/httpd:version1.0")
+	if err != nil {
+		t.Errorf("failed to parse remote image name: %v", err)
+	}
+	if n.Registry != "myregistryhost:5000" {
+		t.Errorf("Incorrect remote registry: %v", n.Registry)
+	}
+	if n.Repository != "fedora" {
+		t.Errorf("Incorrect remote repository: %v", n.Repository)
+	}
+	if n.Name != "httpd" {
+		t.Errorf("Incorrect remote name: %v", n.Name)
+	}
+	if n.Tag != "version1.0" {
+		t.Errorf("Incorrect remote tag: %v", n.Tag)
+	}
+	if n.String() != "myregistryhost:5000/fedora/httpd:version1.0" {
+		t.Errorf("Incorrect remote image string: %v", n.String())
+	}
+	n.Digest = "sha256:xxxxx"
+	if n.String() != "myregistryhost:5000/fedora/httpd@sha256:xxxxx" {
+		t.Errorf("Incorrect remote image string w/ digest: %v", n.String())
+	}
+
+	// Image with digest
+	n, err = ParseImageName("myregistryhost:5000/fedora/httpd@sha256:12345")
+	if err != nil {
+		t.Errorf("failed to parse remote image name: %v", err)
+	}
+	if n.Registry != "myregistryhost:5000" {
+		t.Errorf("Incorrect remote registry: %v", n.Registry)
+	}
+	if n.Repository != "fedora" {
+		t.Errorf("Incorrect remote repository: %v", n.Repository)
+	}
+	if n.Name != "httpd" {
+		t.Errorf("Incorrect remote name: %v", n.Name)
+	}
+	if n.Digest != "sha256:12345" {
+		t.Errorf("Incorrect remote tag: %v", n.Tag)
+	}
+	if n.String() != "myregistryhost:5000/fedora/httpd@sha256:12345" {
+		t.Errorf("Incorrect remote image string: %v", n.String())
+	}
+	n.Digest = "sha256:xxxxx"
+	if n.String() != "myregistryhost:5000/fedora/httpd@sha256:xxxxx" {
+		t.Errorf("Incorrect remote image string w/ digest: %v", n.String())
+	}
+}
+
+func TestIdentityResolver(t *testing.T) {
+	r := NewIdentityResolver()
+
+	n, err := ParseImageName("myregistryhost:5000/fedora/httpd:version1.0")
+	if err != nil {
+		t.Fatalf("Failed to parse test name: %v", err)
+	}
+	nCopy := n
+	r.Resolve(&n)
+	if nCopy != n {
+		t.Errorf("Identity resolver altered image: %v", n)
+	}
+}
+
+func TestRegistryResolver(t *testing.T) {
+	reqCount := 0
+	fake := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		reqCount++
+		if r.Method != http.MethodHead {
+			t.Errorf("Wrong method: %v", r.Method)
+		}
+		if r.URL.Path != "/v2/library/busybox/manifests/latest" {
+			t.Errorf("Wrong URL: %v", r.URL)
+		}
+
+		w.Header().Add("Docker-Content-Digest", "sha256:12345")
+	}))
+	defer fake.Close()
+
+	url, err := url.Parse(fake.URL)
+	if err != nil {
+		t.Fatalf("Failed to parse testserver URL: %v", err)
+	}
+
+	n, err := ParseImageName("busybox")
+	if err != nil {
+		t.Errorf("Failed to parse image: %v", err)
+	}
+	n.Registry = url.Host
+
+	r := NewRegistryResolver(&http.Client{
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+		}})
+	if err := r.Resolve(&n); err != nil {
+		t.Fatalf("Error resolving image name: %v", err)
+	}
+	if reqCount != 1 {
+		t.Errorf("registry resolver required %d requests?", reqCount)
+	}
+	if n.Digest != "sha256:12345" {
+		t.Errorf("registry resolver resolved incorrect digest: %v", n.Digest)
+	}
+
+	// Test cache hit on repeat request
+	n.Digest = ""
+	if err := r.Resolve(&n); err != nil {
+		t.Fatalf("Error re-resolving image name: %v", err)
+	}
+	if reqCount > 1 {
+		t.Errorf("registry resolver repeated cachable request")
+	}
+	if n.Digest != "sha256:12345" {
+		t.Errorf("registry resolver re-resolved incorrect digest: %v", n.Digest)
+	}
+}
-- 
GitLab