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 ®istryResolver{ + 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