diff --git a/pkg/cmd/dockerregistry/dockerregistry.go b/pkg/cmd/dockerregistry/dockerregistry.go index e89fc46f399f..80b7880ab2e0 100644 --- a/pkg/cmd/dockerregistry/dockerregistry.go +++ b/pkg/cmd/dockerregistry/dockerregistry.go @@ -75,7 +75,6 @@ func Execute(configFile io.Reader) { // TODO add https scheme adminRouter := app.NewRoute().PathPrefix("/admin/").Subrouter() - pruneAccessRecords := func(*http.Request) []auth.Access { return []auth.Access{ { @@ -98,6 +97,16 @@ func Execute(configFile io.Reader) { pruneAccessRecords, ) + // Registry extensions endpoint provides extra functionality to handle the image + // signatures. + server.RegisterSignatureHandler(app) + + // Advertise features supported by OpenShift + if app.Config.HTTP.Headers == nil { + app.Config.HTTP.Headers = http.Header{} + } + app.Config.HTTP.Headers.Set("X-Registry-Supports-Signatures", "1") + app.RegisterHealthChecks() handler := alive("/", app) // TODO: temporarily keep for backwards compatibility; remove in the future diff --git a/pkg/dockerregistry/server/auth.go b/pkg/dockerregistry/server/auth.go index 68ead02b1937..c9d42eada650 100644 --- a/pkg/dockerregistry/server/auth.go +++ b/pkg/dockerregistry/server/auth.go @@ -351,6 +351,22 @@ func (ac *AccessController) Authorized(ctx context.Context, accessRecords ...reg } } + case "signature": + namespace, name, err := getNamespaceName(access.Resource.Name) + if err != nil { + return nil, ac.wrapErr(ctx, err) + } + switch access.Action { + case "get": + if err := verifyImageStreamAccess(ctx, namespace, name, access.Action, osClient); err != nil { + return nil, ac.wrapErr(ctx, err) + } + case "put": + if err := verifyImageSignatureAccess(ctx, namespace, name, osClient); err != nil { + return nil, ac.wrapErr(ctx, err) + } + } + case "admin": switch access.Action { case "prune": @@ -435,13 +451,13 @@ func verifyOpenShiftUser(ctx context.Context, client client.UsersInterface) erro return nil } -func verifyImageStreamAccess(ctx context.Context, namespace, imageRepo, verb string, client client.LocalSubjectAccessReviewsNamespacer) error { +func verifyWithSAR(ctx context.Context, resource, namespace, name, verb string, client client.LocalSubjectAccessReviewsNamespacer) error { sar := authorizationapi.LocalSubjectAccessReview{ Action: authorizationapi.Action{ Verb: verb, Group: imageapi.GroupName, - Resource: "imagestreams/layers", - ResourceName: imageRepo, + Resource: resource, + ResourceName: name, }, } response, err := client.LocalSubjectAccessReviews(namespace).Create(&sar) @@ -462,6 +478,14 @@ func verifyImageStreamAccess(ctx context.Context, namespace, imageRepo, verb str return nil } +func verifyImageStreamAccess(ctx context.Context, namespace, imageRepo, verb string, client client.LocalSubjectAccessReviewsNamespacer) error { + return verifyWithSAR(ctx, "imagestreams/layers", namespace, imageRepo, verb, client) +} + +func verifyImageSignatureAccess(ctx context.Context, namespace, imageRepo string, client client.LocalSubjectAccessReviewsNamespacer) error { + return verifyWithSAR(ctx, "imagesignatures", namespace, imageRepo, "create", client) +} + func verifyPruneAccess(ctx context.Context, client client.SubjectAccessReviews) error { sar := authorizationapi.SubjectAccessReview{ Action: authorizationapi.Action{ diff --git a/pkg/dockerregistry/server/signaturedispatcher.go b/pkg/dockerregistry/server/signaturedispatcher.go new file mode 100644 index 000000000000..44c8cc66fa4f --- /dev/null +++ b/pkg/dockerregistry/server/signaturedispatcher.go @@ -0,0 +1,193 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + + kapierrors "k8s.io/kubernetes/pkg/api/errors" + + ctxu "github.com/docker/distribution/context" + + "github.com/docker/distribution/context" + "github.com/docker/distribution/registry/api/errcode" + "github.com/docker/distribution/registry/api/v2" + "github.com/docker/distribution/registry/handlers" + + imageapi "github.com/openshift/origin/pkg/image/api" + + gorillahandlers "github.com/gorilla/handlers" +) + +const ( + errGroup = "registry.api.v2" + defaultSchemaVersion = 2 +) + +// signature represents a Docker image signature. +type signature struct { + // Version specifies the schema version + Version int `json:"schemaVersion"` + // Name must be in "sha256:@signatureName" format + Name string `json:"name"` + // Type is optional, of not set it will be defaulted to "AtomicImageV1" + Type string `json:"type"` + // Content contains the signature + Content []byte `json:"content"` +} + +// signatureList represents list of Docker image signatures. +type signatureList struct { + Signatures []signature `json:"signatures"` +} + +var ( + ErrorCodeSignatureInvalid = errcode.Register(errGroup, errcode.ErrorDescriptor{ + Value: "SIGNATURE_INVALID", + Message: "invalid image signature", + HTTPStatusCode: http.StatusBadRequest, + }) + + ErrorCodeSignatureAlreadyExists = errcode.Register(errGroup, errcode.ErrorDescriptor{ + Value: "SIGNATURE_EXISTS", + Message: "image signature already exists", + HTTPStatusCode: http.StatusConflict, + }) +) + +type signatureHandler struct { + ctx *handlers.Context + reference imageapi.DockerImageReference +} + +// SignatureDispatcher handles the GET and PUT requests for signature endpoint. +func SignatureDispatcher(ctx *handlers.Context, r *http.Request) http.Handler { + signatureHandler := &signatureHandler{ctx: ctx} + signatureHandler.reference, _ = imageapi.ParseDockerImageReference(ctxu.GetStringValue(ctx, "vars.name") + "@" + ctxu.GetStringValue(ctx, "vars.digest")) + + return gorillahandlers.MethodHandler{ + "GET": http.HandlerFunc(signatureHandler.Get), + "PUT": http.HandlerFunc(signatureHandler.Put), + } +} + +func (s *signatureHandler) Put(w http.ResponseWriter, r *http.Request) { + context.GetLogger(s.ctx).Debugf("(*signatureHandler).Put") + if len(s.reference.String()) == 0 { + s.handleError(s.ctx, v2.ErrorCodeNameInvalid.WithDetail("missing image name or image ID"), w) + return + } + + client, ok := UserClientFrom(s.ctx) + if !ok { + s.handleError(s.ctx, errcode.ErrorCodeUnknown.WithDetail("unable to get origin client"), w) + return + } + + sig := signature{} + body, err := ioutil.ReadAll(r.Body) + if err != nil { + s.handleError(s.ctx, ErrorCodeSignatureInvalid.WithDetail(err.Error()), w) + return + } + if err := json.Unmarshal(body, &sig); err != nil { + s.handleError(s.ctx, ErrorCodeSignatureInvalid.WithDetail(err.Error()), w) + return + } + + if len(sig.Type) == 0 { + sig.Type = imageapi.ImageSignatureTypeAtomicImageV1 + } + if sig.Version != defaultSchemaVersion { + s.handleError(s.ctx, ErrorCodeSignatureInvalid.WithDetail(errors.New("only schemaVersion=2 is currently supported")), w) + return + } + newSig := &imageapi.ImageSignature{Content: sig.Content, Type: sig.Type} + newSig.Name = sig.Name + + _, err = client.ImageSignatures().Create(newSig) + switch { + case err == nil: + case kapierrors.IsUnauthorized(err): + s.handleError(s.ctx, errcode.ErrorCodeUnauthorized.WithDetail(err.Error()), w) + return + case kapierrors.IsBadRequest(err): + s.handleError(s.ctx, ErrorCodeSignatureInvalid.WithDetail(err.Error()), w) + return + case kapierrors.IsNotFound(err): + w.WriteHeader(http.StatusNotFound) + return + case kapierrors.IsAlreadyExists(err): + s.handleError(s.ctx, ErrorCodeSignatureAlreadyExists.WithDetail(err.Error()), w) + return + default: + s.handleError(s.ctx, errcode.ErrorCodeUnknown.WithDetail(fmt.Sprintf("unable to create image %s signature: %v", s.reference.String(), err)), w) + return + } + + // Return just 201 with no body. + // TODO: The docker registry actually returns the Location header + w.WriteHeader(http.StatusCreated) + context.GetLogger(s.ctx).Debugf("(*signatureHandler).Put signature successfully added to %s", s.reference.String()) +} + +func (s *signatureHandler) Get(w http.ResponseWriter, req *http.Request) { + context.GetLogger(s.ctx).Debugf("(*signatureHandler).Get") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if len(s.reference.String()) == 0 { + s.handleError(s.ctx, v2.ErrorCodeNameInvalid.WithDetail("missing image name or image ID"), w) + return + } + client, ok := UserClientFrom(s.ctx) + if !ok { + s.handleError(s.ctx, errcode.ErrorCodeUnknown.WithDetail("unable to get origin client"), w) + return + } + + if len(s.reference.ID) == 0 { + s.handleError(s.ctx, v2.ErrorCodeNameInvalid.WithDetail("the image ID must be specified (sha256:"), w) + return + } + + image, err := client.ImageStreamImages(s.reference.Namespace).Get(s.reference.Name, s.reference.ID) + switch { + case err == nil: + case kapierrors.IsUnauthorized(err): + s.handleError(s.ctx, errcode.ErrorCodeUnauthorized.WithDetail(fmt.Sprintf("not authorized to get image %q signature: %v", s.reference.String(), err)), w) + return + case kapierrors.IsNotFound(err): + w.WriteHeader(http.StatusNotFound) + return + default: + s.handleError(s.ctx, errcode.ErrorCodeUnknown.WithDetail(fmt.Sprintf("unable to get image %q signature: %v", s.reference.String(), err)), w) + return + } + + // Transform the OpenShift ImageSignature into Registry signature object. + signatures := signatureList{Signatures: []signature{}} + for _, s := range image.Image.Signatures { + signatures.Signatures = append(signatures.Signatures, signature{ + Version: defaultSchemaVersion, + Name: s.Name, + Type: s.Type, + Content: s.Content, + }) + } + + if data, err := json.Marshal(signatures); err != nil { + s.handleError(s.ctx, errcode.ErrorCodeUnknown.WithDetail(fmt.Sprintf("failed to serialize image signature %v", err)), w) + } else { + w.Write(data) + } +} + +func (s *signatureHandler) handleError(ctx context.Context, err error, w http.ResponseWriter) { + context.GetLogger(ctx).Errorf("(*signatureHandler): %v", err) + ctx, w = context.WithResponseWriter(ctx, w) + if serveErr := errcode.ServeJSON(w, err); serveErr != nil { + context.GetResponseLogger(ctx).Errorf("error sending error response: %v", serveErr) + return + } +} diff --git a/pkg/dockerregistry/server/signaturedispatcher_test.go b/pkg/dockerregistry/server/signaturedispatcher_test.go new file mode 100644 index 000000000000..b1c27b49288d --- /dev/null +++ b/pkg/dockerregistry/server/signaturedispatcher_test.go @@ -0,0 +1,237 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "os" + "reflect" + "testing" + + "github.com/docker/distribution/configuration" + "github.com/docker/distribution/context" + "github.com/docker/distribution/registry/handlers" + _ "github.com/docker/distribution/registry/storage/driver/inmemory" + + kapi "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/client/clientset_generated/internalclientset/fake" + "k8s.io/kubernetes/pkg/client/testing/core" + "k8s.io/kubernetes/pkg/runtime" + + "github.com/openshift/origin/pkg/client/testclient" + registrytest "github.com/openshift/origin/pkg/dockerregistry/testutil" + imagetest "github.com/openshift/origin/pkg/image/admission/testutil" + imageapi "github.com/openshift/origin/pkg/image/api" +) + +func TestSignatureGet(t *testing.T) { + client := &testclient.Fake{} + // TODO: get rid of those nasty global vars + backupRegistryClient := DefaultRegistryClient + DefaultRegistryClient = makeFakeRegistryClient(client, fake.NewSimpleClientset()) + defer func() { + // set it back once this test finishes to make other unit tests working again + DefaultRegistryClient = backupRegistryClient + }() + + ctx := WithUserClient(context.Background(), client) + + installFakeAccessController(t) + + testSignature := imageapi.ImageSignature{ + ObjectMeta: kapi.ObjectMeta{ + Name: "sha256:4028782c08eae4a8c9a28bf661c0a8d1c2fc8e19dbaae2b018b21011197e1484@cddeb7006d914716e2728000746a0b23", + }, + Type: "atomic", + Content: []byte("owGbwMvMwMQorp341GLVgXeMpw9kJDFE1LxLq1ZKLsosyUxOzFGyqlbKTEnNK8ksqQSxU/KTs1OLdItS01KLUvOSU5WslHLygeoy8otLrEwNDAz0S1KLS8CEVU4iiFKq1VHKzE1MT0XSnpuYl5kGlNNNyUwHKbFSKs5INDI1szIxMLIwtzBKNrBITUw1SbRItkw0skhKMzMzTDZItEgxTDZKS7ZINbRMSUpMTDVKMjC0SDIyNDA0NLQ0TzU0sTABWVZSWQByVmJJfm5mskJyfl5JYmZeapFCcWZ6XmJJaVE"), + } + + testImage, err := registrytest.NewImageForManifest("user/app", registrytest.SampleImageManifestSchema1, false) + if err != nil { + t.Fatal(err) + } + testImage.DockerImageManifest = "" + testImage.Signatures = append(testImage.Signatures, testSignature) + + client.AddReactor("get", "images", registrytest.GetFakeImageGetHandler(t, *testImage)) + + testImageStream := registrytest.TestNewImageStreamObject("user", "app", "latest", testImage.Name, testImage.DockerImageReference) + if testImageStream.Annotations == nil { + testImageStream.Annotations = make(map[string]string) + } + testImageStream.Annotations[imageapi.InsecureRepositoryAnnotation] = "true" + client.AddReactor("get", "imagestreams", imagetest.GetFakeImageStreamGetHandler(t, *testImageStream)) + + client.AddReactor("get", "imagestreamimages", registrytest.GetFakeImageStreamImageGetHandler(t, testImageStream, *testImage)) + + registryApp := handlers.NewApp(ctx, &configuration.Configuration{ + Loglevel: "debug", + Auth: map[string]configuration.Parameters{ + fakeAuthorizerName: {"realm": fakeAuthorizerName}, + }, + Storage: configuration.Storage{ + "inmemory": configuration.Parameters{}, + "cache": configuration.Parameters{ + "blobdescriptor": "inmemory", + }, + "delete": configuration.Parameters{ + "enabled": true, + }, + }, + Middleware: map[string][]configuration.Middleware{ + "registry": {{Name: "openshift"}}, + "repository": {{Name: "openshift"}}, + "storage": {{Name: "openshift"}}, + }, + }) + RegisterSignatureHandler(registryApp) + registryServer := httptest.NewServer(registryApp) + defer registryServer.Close() + + serverURL, err := url.Parse(registryServer.URL) + if err != nil { + t.Fatalf("error parsing server url: %v", err) + } + os.Setenv("DOCKER_REGISTRY_URL", serverURL.Host) + + url := fmt.Sprintf("http://%s/extensions/v2/user/app/signatures/%s", serverURL.Host, testImage.Name) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + t.Errorf("failed to make request: %v", err) + } + + httpclient := &http.Client{} + resp, err := httpclient.Do(req) + if err != nil { + t.Fatalf("failed to do the request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected response status: %v", resp.StatusCode) + } + + content, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + if len(content) == 0 { + t.Fatalf("unexpected empty body") + } + + var ans signatureList + + if err := json.Unmarshal(content, &ans); err != nil { + t.Logf("received body: %v", string(content)) + t.Fatalf("failed to parse body: %v", err) + } + + if len(ans.Signatures) == 0 { + t.Fatalf("unexpected empty signature list") + } + + if testSignature.Name != ans.Signatures[0].Name { + t.Fatalf("unexpected signature: %#v", ans) + } +} + +func TestSignaturePut(t *testing.T) { + client := &testclient.Fake{} + // TODO: get rid of those nasty global vars + backupRegistryClient := DefaultRegistryClient + DefaultRegistryClient = makeFakeRegistryClient(client, fake.NewSimpleClientset()) + defer func() { + // set it back once this test finishes to make other unit tests working again + DefaultRegistryClient = backupRegistryClient + }() + + ctx := WithUserClient(context.Background(), client) + + installFakeAccessController(t) + + testSignature := signature{ + Version: 2, + Name: "sha256:4028782c08eae4a8c9a28bf661c0a8d1c2fc8e19dbaae2b018b21011197e1484@cddeb7006d914716e2728000746a0b23", + Type: "atomic", + Content: []byte("owGbwMvMwMQorp341GLVgXeMpw9kJDFE1LxLq1ZKLsosyUxOzFGyqlbKTEnNK8ksqQSxU/KTs1OLdItS01KLUvOSU5WslHLygeoy8otLrEwNDAz0S1KLS8CEVU4iiFKq1VHKzE1MT0XSnpuYl5kGlNNNyUwHKbFSKs5INDI1szIxMLIwtzBKNrBITUw1SbRItkw0skhKMzMzTDZItEgxTDZKS7ZINbRMSUpMTDVKMjC0SDIyNDA0NLQ0TzU0sTABWVZSWQByVmJJfm5mskJyfl5JYmZeapFCcWZ6XmJJaVE"), + } + var newImageSignature *imageapi.ImageSignature + + client.AddReactor("create", "imagesignatures", func(action core.Action) (handled bool, ret runtime.Object, err error) { + sign, ok := action.(core.CreateAction).GetObject().(*imageapi.ImageSignature) + if !ok { + return true, nil, fmt.Errorf("unexpected object received: %#v", sign) + } + newImageSignature = sign + return true, sign, nil + }) + + registryApp := handlers.NewApp(ctx, &configuration.Configuration{ + Loglevel: "debug", + Auth: map[string]configuration.Parameters{ + fakeAuthorizerName: {"realm": fakeAuthorizerName}, + }, + Storage: configuration.Storage{ + "inmemory": configuration.Parameters{}, + "cache": configuration.Parameters{ + "blobdescriptor": "inmemory", + }, + "delete": configuration.Parameters{ + "enabled": true, + }, + }, + Middleware: map[string][]configuration.Middleware{ + "registry": {{Name: "openshift"}}, + "repository": {{Name: "openshift"}}, + "storage": {{Name: "openshift"}}, + }, + }) + RegisterSignatureHandler(registryApp) + registryServer := httptest.NewServer(registryApp) + defer registryServer.Close() + + serverURL, err := url.Parse(registryServer.URL) + if err != nil { + t.Fatalf("error parsing server url: %v", err) + } + os.Setenv("DOCKER_REGISTRY_URL", serverURL.Host) + + signData, err := json.Marshal(testSignature) + if err != nil { + t.Fatalf("unable to serialize signature: %v", err) + } + + url := fmt.Sprintf("http://%s/extensions/v2/user/app/signatures/%s", serverURL.Host, etcdDigest) + + req, err := http.NewRequest("PUT", url, bytes.NewReader(signData)) + if err != nil { + t.Errorf("failed to make request: %v", err) + } + + httpclient := &http.Client{} + resp, err := httpclient.Do(req) + if err != nil { + t.Fatalf("failed to do the request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("unexpected response status: %v", resp.StatusCode) + } + + if testSignature.Name != newImageSignature.Name { + t.Fatalf("unexpected signature: name %#+v", newImageSignature.Name) + } + if testSignature.Type != newImageSignature.Type { + t.Fatalf("unexpected signature: name %#+v", newImageSignature.Name) + } + if !reflect.DeepEqual(testSignature.Content, newImageSignature.Content) { + t.Fatalf("unexpected signature content: %#+v", newImageSignature.Content) + } +} diff --git a/pkg/dockerregistry/server/signaturehandler.go b/pkg/dockerregistry/server/signaturehandler.go new file mode 100644 index 000000000000..e3d3fb662ee3 --- /dev/null +++ b/pkg/dockerregistry/server/signaturehandler.go @@ -0,0 +1,52 @@ +package server + +import ( + "net/http" + + "github.com/docker/distribution/context" + "github.com/docker/distribution/reference" + "github.com/docker/distribution/registry/auth" + "github.com/docker/distribution/registry/handlers" +) + +// RegisterSignatureHandler registers the Docker image signature extension to Docker +// registry. +func RegisterSignatureHandler(app *handlers.App) { + extensionsRouter := app.NewRoute().PathPrefix("/extensions/v2/").Subrouter() + var ( + getSignatureAccess = func(r *http.Request) []auth.Access { + return []auth.Access{ + { + Resource: auth.Resource{ + Type: "signature", + Name: context.GetStringValue(context.WithVars(app, r), "vars.name"), + }, + Action: "get", + }, + } + } + putSignatureAccess = func(r *http.Request) []auth.Access { + return []auth.Access{ + { + Resource: auth.Resource{ + Type: "signature", + Name: context.GetStringValue(context.WithVars(app, r), "vars.name"), + }, + Action: "put", + }, + } + } + ) + app.RegisterRoute( + extensionsRouter.Path("/{name:"+reference.NameRegexp.String()+"}/signatures/{digest:"+reference.DigestRegexp.String()+"}").Methods("GET"), + SignatureDispatcher, + handlers.NameRequired, + getSignatureAccess, + ) + app.RegisterRoute( + extensionsRouter.Path("/{name:"+reference.NameRegexp.String()+"}/signatures/{digest:"+reference.DigestRegexp.String()+"}").Methods("PUT"), + SignatureDispatcher, + handlers.NameRequired, + putSignatureAccess, + ) +} diff --git a/pkg/dockerregistry/testutil/util.go b/pkg/dockerregistry/testutil/util.go index 8836e2db0398..991a4be31a68 100644 --- a/pkg/dockerregistry/testutil/util.go +++ b/pkg/dockerregistry/testutil/util.go @@ -223,13 +223,13 @@ const SampleImageManifestSchema1 = `{ ] }` -// GetFakeImageGetHandler returns a reaction function for use with wake os client returning one of given image +// GetFakeImageGetHandler returns a reaction function for use with fake os client returning one of given image // objects if found. -func GetFakeImageGetHandler(t *testing.T, iss ...imageapi.Image) core.ReactionFunc { +func GetFakeImageGetHandler(t *testing.T, imgs ...imageapi.Image) core.ReactionFunc { return func(action core.Action) (handled bool, ret runtime.Object, err error) { switch a := action.(type) { case core.GetAction: - for _, is := range iss { + for _, is := range imgs { if a.GetName() == is.Name { t.Logf("images get handler: returning image %s", is.Name) return true, &is, nil @@ -266,6 +266,45 @@ func TestNewImageStreamObject(namespace, name, tag, imageName, dockerImageRefere } } +// GetFakeImageStreamImageGetHandler returns a reaction function for use +// with fake os client returning one of given imagestream image objects if found. +func GetFakeImageStreamImageGetHandler(t *testing.T, iss *imageapi.ImageStream, imgs ...imageapi.Image) core.ReactionFunc { + return func(action core.Action) (handled bool, ret runtime.Object, err error) { + switch a := action.(type) { + case core.GetAction: + for _, is := range imgs { + name, imageID, err := imageapi.ParseImageStreamImageName(a.GetName()) + if err != nil { + return true, nil, err + } + + if imageID != is.Name { + continue + } + + t.Logf("imagestreamimage get handler: returning image %s", is.Name) + + isi := imageapi.ImageStreamImage{ + ObjectMeta: kapi.ObjectMeta{ + Namespace: is.Namespace, + Name: imageapi.MakeImageStreamImageName(name, imageID), + CreationTimestamp: is.ObjectMeta.CreationTimestamp, + Annotations: iss.Annotations, + }, + Image: is, + } + + return true, &isi, nil + } + + err := kerrors.NewNotFound(kapi.Resource("imagestreamimages"), a.GetName()) + t.Logf("imagestreamimage get handler: %v", err) + return true, nil, err + } + return false, nil, nil + } +} + type testCredentialStore struct { username string password string