From f43c5240a1bdfc7c927e59116ee866e6f618c4f1 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 13 Nov 2024 14:02:08 -0500 Subject: [PATCH] feat: add distribution API Signed-off-by: Justin --- api/handlers/distribution/distribution.go | 78 +++ .../distribution/distribution_test.go | 122 +++++ api/router/router.go | 19 +- cmd/finch-daemon/router_utils.go | 20 +- e2e/e2e_test.go | 3 + e2e/tests/distribution_inspect.go | 202 ++++++++ e2e/tests/tests.go | 1 + internal/service/distribution/distribution.go | 172 +++++++ .../service/distribution/distribution_test.go | 464 ++++++++++++++++++ internal/service/image/image.go | 34 +- internal/service/image/image_test.go | 3 +- internal/service/image/pull.go | 58 +-- internal/service/image/push.go | 5 +- mocks/mocks_distribution/distributionsvc.go | 52 ++ mocks/mocks_remotes/fetcher.go | 52 ++ mocks/mocks_remotes/resolver.go | 83 ++++ pkg/utility/authutility/utility.go | 70 +++ pkg/utility/imageutility/utility.go | 45 ++ 18 files changed, 1374 insertions(+), 109 deletions(-) create mode 100644 api/handlers/distribution/distribution.go create mode 100644 api/handlers/distribution/distribution_test.go create mode 100644 e2e/tests/distribution_inspect.go create mode 100644 internal/service/distribution/distribution.go create mode 100644 internal/service/distribution/distribution_test.go create mode 100644 mocks/mocks_distribution/distributionsvc.go create mode 100644 mocks/mocks_remotes/fetcher.go create mode 100644 mocks/mocks_remotes/resolver.go create mode 100644 pkg/utility/authutility/utility.go create mode 100644 pkg/utility/imageutility/utility.go diff --git a/api/handlers/distribution/distribution.go b/api/handlers/distribution/distribution.go new file mode 100644 index 0000000..4296b0d --- /dev/null +++ b/api/handlers/distribution/distribution.go @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package distribution + +import ( + "context" + "fmt" + "net/http" + + "github.com/containerd/containerd/namespaces" + "github.com/containerd/nerdctl/pkg/config" + dockertypes "github.com/docker/cli/cli/config/types" + registrytypes "github.com/docker/docker/api/types/registry" + "github.com/gorilla/mux" + "github.com/runfinch/finch-daemon/api/auth" + "github.com/runfinch/finch-daemon/api/response" + "github.com/runfinch/finch-daemon/api/types" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/flog" +) + +//go:generate mockgen --destination=../../../mocks/mocks_distribution/distributionsvc.go -package=mocks_distribution github.com/runfinch/finch-daemon/api/handlers/distribution Service +type Service interface { + Inspect(ctx context.Context, name string, authCfg *dockertypes.AuthConfig) (*registrytypes.DistributionInspect, error) +} + +func RegisterHandlers(r types.VersionedRouter, service Service, conf *config.Config, logger flog.Logger) { + h := newHandler(service, conf, logger) + r.HandleFunc("/distribution/{name:.*}/json", h.inspect, http.MethodGet) +} + +func newHandler(service Service, conf *config.Config, logger flog.Logger) *handler { + return &handler{ + service: service, + Config: conf, + logger: logger, + } +} + +type handler struct { + service Service + Config *config.Config + logger flog.Logger +} + +func (h *handler) inspect(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + // get auth creds from header + authCfg, err := auth.DecodeAuthConfig(r.Header.Get(auth.AuthHeader)) + if err != nil { + response.SendErrorResponse(w, http.StatusBadRequest, fmt.Errorf("failed to decode auth header: %s", err)) + return + } + ctx := namespaces.WithNamespace(r.Context(), h.Config.Namespace) + inspectRes, err := h.service.Inspect(ctx, name, authCfg) + // map the error into http status code and send response. + if err != nil { + var code int + // according to the docs https://docs.docker.com/reference/api/engine/version/v1.47/#tag/Distribution/operation/DistributionInspect + // there are 3 possible error codes: 200, 401, 500 + // in practice, it seems 403 is used rather than 401 and 400 is used for client input errors + switch { + case errdefs.IsInvalidFormat(err): + code = http.StatusBadRequest + case errdefs.IsUnauthenticated(err), errdefs.IsNotFound(err): + code = http.StatusForbidden + default: + code = http.StatusInternalServerError + } + h.logger.Debugf("Inspect Distribution API failed. Status code %d, Message: %s", code, err) + response.SendErrorResponse(w, code, err) + return + } + + // return JSON response + response.JSON(w, http.StatusOK, inspectRes) +} diff --git a/api/handlers/distribution/distribution_test.go b/api/handlers/distribution/distribution_test.go new file mode 100644 index 0000000..e6be297 --- /dev/null +++ b/api/handlers/distribution/distribution_test.go @@ -0,0 +1,122 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package distribution + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/containerd/nerdctl/pkg/config" + registrytypes "github.com/docker/docker/api/types/registry" + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/runfinch/finch-daemon/mocks/mocks_distribution" + "github.com/runfinch/finch-daemon/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// TestDistributionHandler function is the entry point of distribution handler package's unit test using ginkgo. +func TestDistributionHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Distribution APIs Handler") +} + +var _ = Describe("Distribution Inspect API", func() { + var ( + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + service *mocks_distribution.MockService + h *handler + rr *httptest.ResponseRecorder + name string + req *http.Request + ociPlatformAmd ocispec.Platform + ociPlatformArm ocispec.Platform + resp registrytypes.DistributionInspect + respJSON []byte + ) + BeforeEach(func() { + mockCtrl = gomock.NewController(GinkgoT()) + defer mockCtrl.Finish() + logger = mocks_logger.NewLogger(mockCtrl) + service = mocks_distribution.NewMockService(mockCtrl) + c := config.Config{} + h = newHandler(service, &c, logger) + rr = httptest.NewRecorder() + name = "test-image" + var err error + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/distribution/%s/json", name), nil) + Expect(err).Should(BeNil()) + req = mux.SetURLVars(req, map[string]string{"name": name}) + ociPlatformAmd = ocispec.Platform{ + Architecture: "amd64", + OS: "linux", + } + ociPlatformArm = ocispec.Platform{ + Architecture: "amd64", + OS: "linux", + } + resp = registrytypes.DistributionInspect{ + Descriptor: ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: "sha256:9bae60c369e612488c2a089c38737277a4823a3af97ec6866c3b4ad05251bfa5", + Size: 2, + URLs: []string{}, + Annotations: map[string]string{}, + Data: []byte{}, + Platform: &ociPlatformAmd, + }, + Platforms: []ocispec.Platform{ + ociPlatformAmd, + ociPlatformArm, + }, + } + respJSON, err = json.Marshal(resp) + Expect(err).Should(BeNil()) + }) + Context("handler", func() { + It("should return inspect object and 200 status code upon success", func() { + service.EXPECT().Inspect(gomock.Any(), name, gomock.Any()).Return(&resp, nil) + + // handler should return response object with 200 status code + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(respJSON)) + Expect(rr).Should(HaveHTTPStatus(http.StatusOK)) + }) + It("should return 403 status code if image resolution fails due to lack of credentials", func() { + service.EXPECT().Inspect(gomock.Any(), name, gomock.Any()).Return(nil, errdefs.NewForbidden(fmt.Errorf("access denied"))) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + // handler should return error message with 404 status code + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "access denied"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusForbidden)) + }) + It("should return 404 status code if image was not found", func() { + service.EXPECT().Inspect(gomock.Any(), name, gomock.Any()).Return(nil, errdefs.NewNotFound(fmt.Errorf("no such image"))) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + // handler should return error message with 404 status code + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "no such image"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusNotFound)) + }) + It("should return 500 status code if service returns an error message", func() { + service.EXPECT().Inspect(gomock.Any(), name, gomock.Any()).Return(nil, fmt.Errorf("error")) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + // handler should return error message + h.inspect(rr, req) + Expect(rr.Body).Should(MatchJSON(`{"message": "error"}`)) + Expect(rr).Should(HaveHTTPStatus(http.StatusInternalServerError)) + }) + }) +}) diff --git a/api/router/router.go b/api/router/router.go index 46c1991..e149c31 100644 --- a/api/router/router.go +++ b/api/router/router.go @@ -17,6 +17,7 @@ import ( "github.com/runfinch/finch-daemon/api/handlers/builder" "github.com/runfinch/finch-daemon/api/handlers/container" + "github.com/runfinch/finch-daemon/api/handlers/distribution" "github.com/runfinch/finch-daemon/api/handlers/exec" "github.com/runfinch/finch-daemon/api/handlers/image" "github.com/runfinch/finch-daemon/api/handlers/network" @@ -31,14 +32,15 @@ import ( // Options defines the router options to be passed into the handlers. type Options struct { - Config *config.Config - ContainerService container.Service - ImageService image.Service - NetworkService network.Service - SystemService system.Service - BuilderService builder.Service - VolumeService volume.Service - ExecService exec.Service + Config *config.Config + ContainerService container.Service + ImageService image.Service + NetworkService network.Service + SystemService system.Service + BuilderService builder.Service + VolumeService volume.Service + ExecService exec.Service + DistributionService distribution.Service // NerdctlWrapper wraps the interactions with nerdctl to build NerdctlWrapper *backend.NerdctlWrapper @@ -59,6 +61,7 @@ func New(opts *Options) http.Handler { builder.RegisterHandlers(vr, opts.BuilderService, opts.Config, logger, opts.NerdctlWrapper) volume.RegisterHandlers(vr, opts.VolumeService, opts.Config, logger) exec.RegisterHandlers(vr, opts.ExecService, opts.Config, logger) + distribution.RegisterHandlers(vr, opts.DistributionService, opts.Config, logger) return ghandlers.LoggingHandler(os.Stderr, r) } diff --git a/cmd/finch-daemon/router_utils.go b/cmd/finch-daemon/router_utils.go index ceec574..fa7af47 100644 --- a/cmd/finch-daemon/router_utils.go +++ b/cmd/finch-daemon/router_utils.go @@ -17,6 +17,7 @@ import ( "github.com/runfinch/finch-daemon/internal/backend" "github.com/runfinch/finch-daemon/internal/service/builder" "github.com/runfinch/finch-daemon/internal/service/container" + "github.com/runfinch/finch-daemon/internal/service/distribution" "github.com/runfinch/finch-daemon/internal/service/exec" "github.com/runfinch/finch-daemon/internal/service/image" "github.com/runfinch/finch-daemon/internal/service/network" @@ -101,14 +102,15 @@ func createRouterOptions( tarExtractor := archive.NewTarExtractor(ecc.NewExecCmdCreator(), logger) return &router.Options{ - Config: conf, - ContainerService: container.NewService(clientWrapper, ncWrapper, logger, fs, tarCreator, tarExtractor), - ImageService: image.NewService(clientWrapper, ncWrapper, logger), - NetworkService: network.NewService(clientWrapper, ncWrapper, logger), - SystemService: system.NewService(clientWrapper, ncWrapper, logger), - BuilderService: builder.NewService(clientWrapper, ncWrapper, logger, tarExtractor), - VolumeService: volume.NewService(ncWrapper, logger), - ExecService: exec.NewService(clientWrapper, logger), - NerdctlWrapper: ncWrapper, + Config: conf, + ContainerService: container.NewService(clientWrapper, ncWrapper, logger, fs, tarCreator, tarExtractor), + ImageService: image.NewService(clientWrapper, ncWrapper, logger), + NetworkService: network.NewService(clientWrapper, ncWrapper, logger), + SystemService: system.NewService(clientWrapper, ncWrapper, logger), + BuilderService: builder.NewService(clientWrapper, ncWrapper, logger, tarExtractor), + VolumeService: volume.NewService(ncWrapper, logger), + ExecService: exec.NewService(clientWrapper, logger), + DistributionService: distribution.NewService(clientWrapper, ncWrapper, logger), + NerdctlWrapper: ncWrapper, } } diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 9e10821..5fb808d 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -67,6 +67,9 @@ func TestRun(t *testing.T) { // functional test for system api tests.SystemVersion(opt) tests.SystemEvents(opt) + + // functional test for distribution api + tests.DistributionInspect(opt) }) gomega.RegisterFailHandler(ginkgo.Fail) diff --git a/e2e/tests/distribution_inspect.go b/e2e/tests/distribution_inspect.go new file mode 100644 index 0000000..7ee74bb --- /dev/null +++ b/e2e/tests/distribution_inspect.go @@ -0,0 +1,202 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package tests + +import ( + b64 "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/onsi/gomega/gbytes" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/ffs" + "github.com/runfinch/common-tests/fnet" + "github.com/runfinch/common-tests/option" + + registrytypes "github.com/docker/docker/api/types/registry" + "github.com/runfinch/finch-daemon/api/response" + "github.com/runfinch/finch-daemon/e2e/client" +) + +// DistributionInspect tests `GET distribution/{name:.*}` API. +func DistributionInspect(opt *option.Option) { + Describe("inspect a distribution", func() { + var ( + uClient *http.Client + version string + registry string + authImageTag string + ) + BeforeEach(func() { + // create a custom client to use http over unix sockets + uClient = client.NewClient(GetDockerHostUrl()) + // get the docker api version that will be tested + version = GetDockerApiVersion() + }) + + BeforeEach(func() { + filename := "htpasswd" + // The htpasswd is generated by + // ` run --entrypoint htpasswd public.ecr.aws/docker/library/httpd:2 -Bbn testUser testPassword`. + // We don't want to generate it on the fly because: + // 1. Pulling the httpd image can take a long time, sometimes even more 10 seconds. + // 2. It's unlikely that we will have to update this in the future. + // 3. It's not the thing we want to validate by the functional tests. We only want the output produced by it. + // Same password/logic from runfinch/common-tests + //nolint:gosec // This password is only used for testing purpose. + htpasswd := "testUser:$2y$05$wE0sj3r9O9K9q7R0MXcfPuIerl/06L1IsxXkCuUr3QZ8lHWwicIdS" + htpasswdDir := filepath.Dir(ffs.CreateTempFile(filename, htpasswd)) + ginkgo.DeferCleanup(os.RemoveAll, htpasswdDir) + port := fnet.GetFreePort() + command.Run(opt, "run", + "-dp", fmt.Sprintf("%d:5000", port), + "--name", "registry", + "-v", fmt.Sprintf("%s:/auth", htpasswdDir), + "-e", "REGISTRY_AUTH=htpasswd", + "-e", "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm", + "-e", fmt.Sprintf("REGISTRY_AUTH_HTPASSWD_PATH=/auth/%s", filename), + registryImage) + registry = fmt.Sprintf(`localhost:%d`, port) + authImageTag = fmt.Sprintf(`%s/test-login:tag`, registry) + buildContext := ffs.CreateBuildContext(fmt.Sprintf(`FROM %s + CMD ["echo", "bar"] + `, defaultImage)) + ginkgo.DeferCleanup(os.RemoveAll, buildContext) + command.Run(opt, "build", "-t", authImageTag, buildContext) + }) + + AfterEach(func() { + command.RemoveAll(opt) + }) + + It("should inspect distribution of alpine image", func() { + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/distribution/%s/json", newerAlpineImage)) + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + + Expect(res).To(HaveHTTPStatus(http.StatusOK)) + var d registrytypes.DistributionInspect + err = json.NewDecoder(res.Body).Decode(&d) + Expect(err).Should(BeNil()) + Expect(d.Descriptor).ShouldNot(BeNil()) + Expect(d.Descriptor.MediaType).Should(Equal("application/vnd.oci.image.index.v1+json")) + // since the image is built and pushed on the test runner, the following aspects are OS/architecture dependent + // and not strictly checked + Expect(d.Descriptor.Digest).Should(MatchRegexp(`sha256:\w{64}`)) + Expect(d.Descriptor.Size).Should(BeNumerically(">", 0)) + Expect(d.Descriptor.URLs).Should(HaveLen(0)) + Expect(d.Descriptor.Data).Should(BeNil()) + Expect(d.Descriptor.Platform).Should(BeNil()) + Expect(d.Descriptor.ArtifactType).Should(Equal("")) + Expect(d.Platforms).ShouldNot(BeNil()) + }) + + It("should inspect distribution of older alpine image", func() { + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/distribution/%s/json", olderAlpineImage)) + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + + Expect(res).To(HaveHTTPStatus(http.StatusOK)) + var d registrytypes.DistributionInspect + err = json.NewDecoder(res.Body).Decode(&d) + Expect(err).Should(BeNil()) + Expect(d.Descriptor).ShouldNot(BeNil()) + Expect(d.Descriptor.MediaType).Should(Equal("application/vnd.docker.distribution.manifest.list.v2+json")) + // since the image is built and pushed on the test runner, the following aspects are OS/architecture dependent + // and not strictly checked + Expect(d.Descriptor.Digest).Should(MatchRegexp(`sha256:\w{64}`)) + Expect(d.Descriptor.Size).Should(BeNumerically(">", 0)) + Expect(d.Descriptor.URLs).Should(HaveLen(0)) + Expect(d.Descriptor.Data).Should(BeNil()) + Expect(d.Descriptor.Platform).Should(BeNil()) + Expect(d.Descriptor.ArtifactType).Should(Equal("")) + Expect(d.Platforms).ShouldNot(BeNil()) + }) + + It("should fail to inspect a non-existent image", func() { + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/distribution/%s/json", nonexistentImageName)) + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + Expect(res).To(HaveHTTPStatus(http.StatusForbidden)) + + var message response.Error + err = json.NewDecoder(res.Body).Decode(&message) + Expect(err).Should(BeNil()) + Expect(message.Message).Should(Equal("pull access denied, repository does not exist or " + + "may require authorization: server message: insufficient_scope: authorization failed")) + }) + + It("should fail to inpsect an image with a malformed image name", func() { + malformedImage := "alpine:image:latest" + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/distribution/%s/json", malformedImage)) + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + Expect(res).To(HaveHTTPStatus(http.StatusBadRequest)) + + var message response.Error + err = json.NewDecoder(res.Body).Decode(&message) + Expect(err).Should(BeNil()) + Expect(message.Message).Should(Equal("invalid reference format")) + }) + + It("should inspect an image with registry credentials when logged in", func() { + command.New(opt, "login", registry, "-u", testUser, "--password-stdin"). + WithStdin(gbytes.BufferWithBytes([]byte(testPassword))).Run() + ginkgo.DeferCleanup(func() { + command.Run(opt, "logout", registry) + }) + command.Run(opt, "push", authImageTag) + + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/distribution/%s/json", authImageTag)) + + req, err := http.NewRequest(http.MethodGet, relativeUrl, nil) + req.Header.Set("X-Registry-Auth", b64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{ + "username": "%s", + "password": "%s", + "serveraddress": "%s" +}`, testUser, testPassword, registry)))) + res, err := uClient.Do(req) + Expect(err).Should(BeNil()) + + Expect(res).To(HaveHTTPStatus(http.StatusOK)) + var d registrytypes.DistributionInspect + err = json.NewDecoder(res.Body).Decode(&d) + Expect(err).Should(BeNil()) + Expect(d.Descriptor).ShouldNot(BeNil()) + Expect(d.Descriptor.MediaType).Should(Equal("application/vnd.docker.distribution.manifest.v2+json")) + // since the image is built and pushed on the test runner, the following aspects are OS/architecture dependent + // and not strictly checked + Expect(d.Descriptor.Digest).Should(MatchRegexp(`sha256:\w{64}`)) + Expect(d.Descriptor.Size).Should(BeNumerically(">", 0)) + Expect(d.Descriptor.URLs).Should(HaveLen(0)) + Expect(d.Descriptor.Data).Should(BeNil()) + Expect(d.Descriptor.Platform).Should(BeNil()) + Expect(d.Descriptor.ArtifactType).Should(Equal("")) + Expect(d.Platforms).ShouldNot(BeNil()) + }) + + It("should fail to inspect an image which needs registry credentials when not logged in", func() { + command.New(opt, "login", registry, "-u", testUser, "--password-stdin"). + WithStdin(gbytes.BufferWithBytes([]byte(testPassword))).Run() + command.Run(opt, "push", authImageTag) + command.Run(opt, "logout", registry) + + relativeUrl := client.ConvertToFinchUrl(version, fmt.Sprintf("/distribution/%s/json", authImageTag)) + res, err := uClient.Get(relativeUrl) + Expect(err).Should(BeNil()) + + var message response.Error + err = json.NewDecoder(res.Body).Decode(&message) + Expect(err).Should(BeNil()) + Expect(message.Message).Should(Equal(fmt.Sprintf("unexpected status from HEAD request "+ + "to http://%s/v2/test-login/manifests/tag: 401 Unauthorized", registry))) + }) + }) +} diff --git a/e2e/tests/tests.go b/e2e/tests/tests.go index 0a2e28d..932827a 100644 --- a/e2e/tests/tests.go +++ b/e2e/tests/tests.go @@ -27,6 +27,7 @@ import ( const ( alpineImage = "public.ecr.aws/docker/library/alpine:latest" + newerAlpineImage = "public.ecr.aws/docker/library/alpine:3.21" olderAlpineImage = "public.ecr.aws/docker/library/alpine:3.13" amazonLinux2Image = "public.ecr.aws/amazonlinux/amazonlinux:2" nginxImage = "public.ecr.aws/docker/library/nginx:latest" diff --git a/internal/service/distribution/distribution.go b/internal/service/distribution/distribution.go new file mode 100644 index 0000000..21f4b8c --- /dev/null +++ b/internal/service/distribution/distribution.go @@ -0,0 +1,172 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package distribution + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + + containerdimages "github.com/containerd/containerd/images" + dockerresolver "github.com/containerd/containerd/remotes/docker" + cremoteerrors "github.com/containerd/containerd/remotes/errors" + cerrdefs "github.com/containerd/errdefs" + "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + dockertypes "github.com/docker/cli/cli/config/types" + registrytypes "github.com/docker/docker/api/types/registry" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/runfinch/finch-daemon/api/handlers/distribution" + "github.com/runfinch/finch-daemon/internal/backend" + "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/flog" + "github.com/runfinch/finch-daemon/pkg/utility/authutility" + "github.com/runfinch/finch-daemon/pkg/utility/imageutility" +) + +type service struct { + client backend.ContainerdClient + nctlImageSvc backend.NerdctlImageSvc + logger flog.Logger +} + +// setting getAuthCredsFunc as a variable to allow mocking this function for unit testing. +var getAuthCredsFunc = authutility.GetAuthCreds + +func NewService(client backend.ContainerdClient, nerdctlImageSvc backend.NerdctlImageSvc, logger flog.Logger) distribution.Service { + return &service{ + client: client, + nctlImageSvc: nerdctlImageSvc, + logger: logger, + } +} + +func (s *service) Inspect(ctx context.Context, name string, ac *dockertypes.AuthConfig) (*registrytypes.DistributionInspect, error) { + // Canonicalize and parse raw image reference as "image:tag" or "image@digest" + rawRef, err := imageutility.Canonicalize(name, "") + if err != nil { + return nil, errdefs.NewInvalidFormat(err) + } + namedRef, refDomain, err := s.client.ParseDockerRef(rawRef) + if err != nil { + return nil, errdefs.NewInvalidFormat(err) + } + + // get auth creds and the corresponding docker remotes resolver + var creds dockerconfigresolver.AuthCreds + if ac != nil { + creds, err = getAuthCredsFunc(refDomain, s.client, *ac) + if err != nil { + return nil, err + } + } + resolver, _, err := s.nctlImageSvc.GetDockerResolver(ctx, refDomain, creds) + if err != nil { + return nil, fmt.Errorf("failed to initialize remotes resolver: %s", err) + } + + _, desc, err := resolver.Resolve(ctx, namedRef) + if err != nil { + // translate error definitions from containerd + switch { + case cerrdefs.IsNotFound(err): + return nil, errdefs.NewNotFound(err) + case errors.Is(err, dockerresolver.ErrInvalidAuthorization): + return nil, errdefs.NewUnauthenticated(err) + default: + // this error is thrown when auth fails + var errStatus cremoteerrors.ErrUnexpectedStatus + if errors.As(err, &errStatus) { + return nil, errdefs.NewUnauthenticated(err) + } + return nil, err + } + } + + fetcher, err := resolver.Fetcher(ctx, namedRef) + if err != nil { + return nil, err + } + + rc, err := fetcher.Fetch(ctx, desc) + if err != nil { + switch { + case cerrdefs.IsNotFound(err): + return nil, errdefs.NewNotFound(err) + default: + return nil, err + } + } + + res, err := io.ReadAll(rc) + if err != nil { + return nil, fmt.Errorf("failed to read fetch result: %w", err) + } + + if dgst := desc.Digest.Algorithm().FromBytes(res); dgst != desc.Digest { + return nil, fmt.Errorf("digest mismatch: %s != %s", dgst, desc.Digest) + } + + var platforms []ocispec.Platform + switch { + case desc.MediaType == ocispec.MediaTypeImageManifest || + desc.MediaType == containerdimages.MediaTypeDockerSchema2Manifest: + var manifest ocispec.Manifest + if err := json.Unmarshal(res, &manifest); err != nil { + return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) + } + + // fetch the image to get the platform + rc, err := fetcher.Fetch(ctx, manifest.Config) + if err != nil { + switch { + case cerrdefs.IsNotFound(err): + return nil, errdefs.NewNotFound(err) + default: + return nil, err + } + } + + imageRes, err := io.ReadAll(rc) + if err != nil { + return nil, fmt.Errorf("failed to read image: %w", err) + } + + if dgst := manifest.Config.Digest.Algorithm().FromBytes(imageRes); dgst != manifest.Config.Digest { + return nil, fmt.Errorf("image digest mismatch: %s != %s", dgst, manifest.Config.Digest) + } + + var image ocispec.Image + if err := json.Unmarshal(imageRes, &image); err != nil { + return nil, fmt.Errorf("failed to unmarshal image: %w", err) + } + + platforms = []ocispec.Platform{image.Platform} + case desc.MediaType == ocispec.MediaTypeImageIndex || + desc.MediaType == containerdimages.MediaTypeDockerSchema2ManifestList: + var index ocispec.Index + if err := json.Unmarshal(res, &index); err != nil { + return nil, fmt.Errorf("failed to unmarshal index: %w", err) + } + for _, manifest := range index.Manifests { + platforms = append(platforms, *manifest.Platform) + } + } + + return ®istrytypes.DistributionInspect{ + Descriptor: ocispec.Descriptor{ + MediaType: desc.MediaType, + Digest: desc.Digest, + Size: desc.Size, + URLs: desc.URLs, + Annotations: desc.Annotations, + Data: desc.Data, + Platform: desc.Platform, + ArtifactType: desc.ArtifactType, + }, + Platforms: platforms, + }, nil +} diff --git a/internal/service/distribution/distribution_test.go b/internal/service/distribution/distribution_test.go new file mode 100644 index 0000000..73bf8a6 --- /dev/null +++ b/internal/service/distribution/distribution_test.go @@ -0,0 +1,464 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package distribution + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" + + "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + dockertypes "github.com/docker/cli/cli/config/types" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/runfinch/finch-daemon/internal/backend" + "github.com/runfinch/finch-daemon/mocks/mocks_backend" + "github.com/runfinch/finch-daemon/mocks/mocks_logger" + "github.com/runfinch/finch-daemon/mocks/mocks_remotes" + "github.com/runfinch/finch-daemon/pkg/errdefs" +) + +// TestImageHandler function is the entry point of image service package's unit test using ginkgo. +func TestDistributionService(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "UnitTests - Distribution APIs Service") +} + +// Unit tests related to distribution inspect API. +var _ = Describe("Distribution Inspect API ", func() { + Context("service", func() { + var ( + ctx context.Context + mockCtrl *gomock.Controller + logger *mocks_logger.Logger + cdClient *mocks_backend.MockContainerdClient + ncClient *mocks_backend.MockNerdctlImageSvc + mockResolver *mocks_remotes.MockResolver + mockFetcher *mocks_remotes.MockFetcher + name string + tag string + imageRef string + domain string + ociPlatformAmd ocispec.Platform + ociPlatformArm ocispec.Platform + authCfg dockertypes.AuthConfig + authCreds dockerconfigresolver.AuthCreds + imageIndexDescriptor ocispec.Descriptor + imageDescriptor1 ocispec.Descriptor + imageDescriptor2 ocispec.Descriptor + imageIndex ocispec.Index + image ocispec.Image + imageManifestDescriptor ocispec.Descriptor + imageManifest ocispec.Manifest + imageManifestBytes []byte + imageBytes []byte + imageIndexBytes []byte + s service + ) + BeforeEach(func() { + ctx = context.Background() + // initialize mocks + mockCtrl = gomock.NewController(GinkgoT()) + logger = mocks_logger.NewLogger(mockCtrl) + cdClient = mocks_backend.NewMockContainerdClient(mockCtrl) + ncClient = mocks_backend.NewMockNerdctlImageSvc(mockCtrl) + mockResolver = mocks_remotes.NewMockResolver(mockCtrl) + mockFetcher = mocks_remotes.NewMockFetcher(mockCtrl) + name = "public.ecr.aws/test-image/test-image" + tag = "test-tag" + imageRef = fmt.Sprintf("%s:%s", name, tag) + domain = "public.ecr.aws" + ociPlatformAmd = ocispec.Platform{ + Architecture: "amd64", + OS: "linux", + } + ociPlatformArm = ocispec.Platform{ + Architecture: "amd64", + OS: "linux", + } + authCfg = dockertypes.AuthConfig{ + Username: "test-user", + Password: "test-password", + } + authCreds = func(_ string) (string, string, error) { + return authCfg.Username, authCfg.Password, nil + } + + imageIndexDescriptor = ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageIndex, + Digest: "sha256:9bae60c369e612488c2a089c38737277a4823a3af97ec6866c3b4ad05251bfa5", + Size: 2, + URLs: []string{}, + Annotations: map[string]string{}, + Data: []byte{}, + Platform: &ociPlatformAmd, + } + + imageDescriptor1 = ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: "sha256:deadbeef", + Size: 2, + URLs: []string{}, + Annotations: map[string]string{}, + Data: []byte{}, + Platform: &ociPlatformAmd, + } + + imageDescriptor2 = ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: "sha256:decafbad", + Size: 2, + URLs: []string{}, + Annotations: map[string]string{}, + Data: []byte{}, + Platform: &ociPlatformArm, + } + + imageIndex = ocispec.Index{ + Versioned: specs.Versioned{ + SchemaVersion: 1, + }, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{ + imageDescriptor1, + imageDescriptor2, + }, + } + b, err := json.Marshal(imageIndex) + Expect(err).ShouldNot(HaveOccurred()) + imageIndexBytes = b + + imageManifestDescriptor = ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: "sha256:9b13590c9a50929020dc76a30ad813e42514a4e34de2f04f5a088f5a1320367c", + Size: 2, + URLs: []string{}, + Annotations: map[string]string{}, + Data: []byte{}, + } + + imageManifest = ocispec.Manifest{ + MediaType: ocispec.MediaTypeImageManifest, + Config: ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: "sha256:58cc9abebfec4b5ee95157d060207f7bc302516e6d84a0d83a560a1f7ed00e6e", + Size: 2, + URLs: []string{}, + Annotations: map[string]string{}, + Data: []byte{}, + Platform: &ocispec.Platform{}, + ArtifactType: "", + }, + } + b, err = json.Marshal(imageManifest) + Expect(err).ShouldNot(HaveOccurred()) + imageManifestBytes = b + + image = ocispec.Image{ + Platform: ociPlatformAmd, + } + b, err = json.Marshal(image) + Expect(err).ShouldNot(HaveOccurred()) + imageBytes = b + + s = service{ + client: cdClient, + nctlImageSvc: ncClient, + logger: logger, + } + }) + + It("should return an error when canonicalization fails due to invalid input", func() { + inspect, err := s.Inspect(ctx, "sdfsdfsdfsdf:invalid@digest", &authCfg) + Expect(err).Should(HaveOccurred()) + Expect(errdefs.IsInvalidFormat(err)).Should(BeTrue()) + Expect(inspect).Should(BeNil()) + }) + + It("should return an error when ParseDockerRef fails due to invalid input", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + "", "", fmt.Errorf("parsing failed"), + ) + + inspect, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).Should(HaveOccurred()) + Expect(errdefs.IsInvalidFormat(err)).Should(BeTrue()) + Expect(inspect).Should(BeNil()) + }) + + It("should return an error when getAuthCredsFunc fails", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + nil, fmt.Errorf("invalid credentials"), + ) + + inspect, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("invalid credentials")) + Expect(inspect).Should(BeNil()) + }) + + It("should return an error when getting the docker resolver fails", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + mockResolver, nil, fmt.Errorf("resolver error"), + ) + + inspect, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("resolver error")) + Expect(inspect).Should(BeNil()) + }) + + It("should return an error when Resolving fails", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + mockResolver, nil, nil, + ) + + mockResolver.EXPECT().Resolve(gomock.Any(), imageRef).Return("", ocispec.Descriptor{}, fmt.Errorf("failed to resolve")) + + inspect, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("failed to resolve")) + Expect(inspect).Should(BeNil()) + }) + + It("should return an error when creating Fetcher fails", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + mockResolver, nil, nil, + ) + + mockResolver.EXPECT().Resolve(gomock.Any(), imageRef).Return("", imageIndexDescriptor, nil) + mockResolver.EXPECT().Fetcher(gomock.Any(), imageRef).Return(nil, fmt.Errorf("failed to create fetcher")) + + inspect, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("failed to create fetcher")) + Expect(inspect).Should(BeNil()) + }) + + It("should return an error when Fetcher fails to Fetch", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + mockResolver, nil, nil, + ) + + mockResolver.EXPECT().Resolve(gomock.Any(), imageRef).Return("", imageIndexDescriptor, nil) + mockResolver.EXPECT().Fetcher(gomock.Any(), imageRef).Return(mockFetcher, nil) + mockFetcher.EXPECT().Fetch(gomock.Any(), imageIndexDescriptor).Return(nil, fmt.Errorf("fetcher failed to fetch")) + + inspect, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("fetcher failed to fetch")) + Expect(inspect).Should(BeNil()) + }) + + It("should return an error when reading manifest fails", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + mockResolver, nil, nil, + ) + + mockResolver.EXPECT().Resolve(gomock.Any(), imageRef).Return("", imageIndexDescriptor, nil) + mockResolver.EXPECT().Fetcher(gomock.Any(), imageRef).Return(mockFetcher, nil) + imageIndexRc := io.NopCloser(&mockReader{ + err: fmt.Errorf("failed to read"), + }) + mockFetcher.EXPECT().Fetch(gomock.Any(), imageIndexDescriptor).Return(imageIndexRc, nil) + + inspect, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("failed to read")) + Expect(inspect).Should(BeNil()) + }) + + When("Image index", func() { + It("should return expected response upon success", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + mockResolver, nil, nil, + ) + + mockResolver.EXPECT().Resolve(gomock.Any(), imageRef).Return("", imageIndexDescriptor, nil) + mockResolver.EXPECT().Fetcher(gomock.Any(), imageRef).Return(mockFetcher, nil) + imageIndexRc := io.NopCloser(strings.NewReader(string(imageIndexBytes))) + mockFetcher.EXPECT().Fetch(gomock.Any(), imageIndexDescriptor).Return(imageIndexRc, nil) + + inspectRes, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).ShouldNot(HaveOccurred()) + Expect(inspectRes).ShouldNot(BeNil()) + Expect(inspectRes.Descriptor).Should(Equal(imageIndexDescriptor)) + Expect(inspectRes.Platforms).Should(HaveLen(2)) + Expect(inspectRes.Platforms).Should(ContainElements(ociPlatformAmd, ociPlatformArm)) + }) + }) + + When("Image", func() { + It("should return expected response upon success", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + mockResolver, nil, nil, + ) + + mockResolver.EXPECT().Resolve(gomock.Any(), imageRef).Return("", imageManifestDescriptor, nil) + mockResolver.EXPECT().Fetcher(gomock.Any(), imageRef).Return(mockFetcher, nil) + imageManifestRc := io.NopCloser(strings.NewReader(string(imageManifestBytes))) + mockFetcher.EXPECT().Fetch(gomock.Any(), imageManifestDescriptor).Return(imageManifestRc, nil) + + imageRc := io.NopCloser(strings.NewReader(string(imageBytes))) + // gomock.Any() used for second argument because comparing maps compares addresses, which + // will never be equal due to (un)marshalling + mockFetcher.EXPECT().Fetch(gomock.Any(), gomock.Any()).Return(imageRc, nil) + + inspectRes, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).ShouldNot(HaveOccurred()) + Expect(inspectRes).ShouldNot(BeNil()) + Expect(inspectRes.Descriptor).Should(Equal(imageManifestDescriptor)) + Expect(inspectRes.Platforms).Should(HaveLen(1)) + Expect(inspectRes.Platforms).Should(ContainElement(ociPlatformAmd)) + }) + + It("should return an error when image Fetcher fails to Fetch", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + mockResolver, nil, nil, + ) + + mockResolver.EXPECT().Resolve(gomock.Any(), imageRef).Return("", imageManifestDescriptor, nil) + mockResolver.EXPECT().Fetcher(gomock.Any(), imageRef).Return(mockFetcher, nil) + imageManifestRc := io.NopCloser(strings.NewReader(string(imageManifestBytes))) + mockFetcher.EXPECT().Fetch(gomock.Any(), imageManifestDescriptor).Return(imageManifestRc, nil) + mockFetcher.EXPECT().Fetch(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("image fetcher failed to fetch")) + + inspect, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("image fetcher failed to fetch")) + Expect(inspect).Should(BeNil()) + }) + + It("should return an error when reading image fails", func() { + cdClient.EXPECT().ParseDockerRef(imageRef).Return( + imageRef, domain, nil, + ) + expectGetAuthCreds(mockCtrl, domain, authCfg).Return( + authCreds, nil, + ) + + ncClient.EXPECT().GetDockerResolver(gomock.Any(), domain, gomock.Not(gomock.Nil())).Return( + mockResolver, nil, nil, + ) + + mockResolver.EXPECT().Resolve(gomock.Any(), imageRef).Return("", imageManifestDescriptor, nil) + mockResolver.EXPECT().Fetcher(gomock.Any(), imageRef).Return(mockFetcher, nil) + imageManifestRc := io.NopCloser(strings.NewReader(string(imageManifestBytes))) + mockFetcher.EXPECT().Fetch(gomock.Any(), imageManifestDescriptor).Return(imageManifestRc, nil) + + imageRc := io.NopCloser(&mockReader{ + err: fmt.Errorf("failed to read image"), + }) + mockFetcher.EXPECT().Fetch(gomock.Any(), gomock.Any()).Return(imageRc, nil) + + inspect, err := s.Inspect(ctx, imageRef, &authCfg) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("failed to read image")) + Expect(inspect).Should(BeNil()) + }) + }) + }) +}) + +// expectGetAuthCreds creates a new mocked object for getAuthCreds function +// with expected input parameters. +func expectGetAuthCreds(ctrl *gomock.Controller, refDomain string, ac dockertypes.AuthConfig) *mockGetAuthCreds { + return &mockGetAuthCreds{ + expectedDomain: refDomain, + expectedAuth: ac, + ctrl: ctrl, + } +} + +type mockGetAuthCreds struct { + expectedDomain string + expectedAuth dockertypes.AuthConfig + ctrl *gomock.Controller +} + +// Return mocks getAuthCreds function with expected input parameters and returns the passed output values. +func (m *mockGetAuthCreds) Return(creds dockerconfigresolver.AuthCreds, err error) { + m.ctrl.RecordCall(m, "GetAuthCreds", m.expectedDomain, m.expectedAuth) + getAuthCredsFunc = func(domain string, _ backend.ContainerdClient, ac dockertypes.AuthConfig) (dockerconfigresolver.AuthCreds, error) { + m.GetAuthCreds(domain, ac) + return creds, err + } +} + +func (m *mockGetAuthCreds) GetAuthCreds(domain string, ac dockertypes.AuthConfig) { + m.ctrl.Call(m, "GetAuthCreds", domain, ac) +} + +type mockReader struct { + err error +} + +func (m mockReader) Read([]byte) (int, error) { + return 0, m.err +} diff --git a/internal/service/image/image.go b/internal/service/image/image.go index 0fd9666..cb0fd00 100644 --- a/internal/service/image/image.go +++ b/internal/service/image/image.go @@ -6,19 +6,18 @@ package image import ( "context" "fmt" - "strings" "github.com/containerd/containerd/images" - "github.com/distribution/reference" "github.com/runfinch/finch-daemon/api/handlers/image" "github.com/runfinch/finch-daemon/internal/backend" "github.com/runfinch/finch-daemon/pkg/errdefs" "github.com/runfinch/finch-daemon/pkg/flog" + "github.com/runfinch/finch-daemon/pkg/utility/authutility" ) // setting getAuthCredsFunc as a variable to allow mocking this function for unit testing. -var getAuthCredsFunc = (*service).getAuthCreds +var getAuthCredsFunc = authutility.GetAuthCreds type service struct { client backend.ContainerdClient @@ -73,32 +72,3 @@ const ( tagDigestPrefix = "sha256:" eventType = "image" ) - -func canonicalize(name, tag string) (string, error) { - if name != "" { - if strings.HasPrefix(tag, tagDigestPrefix) { - name += "@" + tag - } else if tag != "" { - name += ":" + tag - } - } else { - name = tag - } - ref, err := reference.ParseAnyReference(name) - if err != nil { - return "", err - } - if named, ok := ref.(reference.Named); ok && refNeedsTag(ref) { - tagged, err := reference.WithTag(named, defaultTag) - if err == nil { - ref = tagged - } - } - return ref.String(), nil -} - -func refNeedsTag(ref reference.Reference) bool { - _, tagged := ref.(reference.Tagged) - _, digested := ref.(reference.Digested) - return !(tagged || digested) -} diff --git a/internal/service/image/image_test.go b/internal/service/image/image_test.go index 00b1f73..c851f7b 100644 --- a/internal/service/image/image_test.go +++ b/internal/service/image/image_test.go @@ -18,6 +18,7 @@ import ( "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/runfinch/finch-daemon/internal/backend" "github.com/runfinch/finch-daemon/mocks/mocks_backend" "github.com/runfinch/finch-daemon/mocks/mocks_logger" "github.com/runfinch/finch-daemon/pkg/errdefs" @@ -151,7 +152,7 @@ type mockGetAuthCreds struct { // Return mocks getAuthCreds function with expected input parameters and returns the passed output values. func (m *mockGetAuthCreds) Return(creds dockerconfigresolver.AuthCreds, err error) { m.ctrl.RecordCall(m, "GetAuthCreds", m.expectedDomain, m.expectedAuth) - getAuthCredsFunc = func(_ *service, domain string, ac dockertypes.AuthConfig) (dockerconfigresolver.AuthCreds, error) { + getAuthCredsFunc = func(domain string, _ backend.ContainerdClient, ac dockertypes.AuthConfig) (dockerconfigresolver.AuthCreds, error) { m.GetAuthCreds(domain, ac) return creds, err } diff --git a/internal/service/image/pull.go b/internal/service/image/pull.go index b8d92fc..8649f68 100644 --- a/internal/service/image/pull.go +++ b/internal/service/image/pull.go @@ -43,7 +43,7 @@ func (s *service) Pull(ctx context.Context, name, tag, platformStr string, ac *d // get auth creds and the corresponding docker remotes resolver var creds dockerconfigresolver.AuthCreds if ac != nil { - creds, err = getAuthCredsFunc(s, refDomain, *ac) + creds, err = getAuthCredsFunc(refDomain, s.client, *ac) if err != nil { return err } @@ -89,59 +89,3 @@ func toImageRef(name, tag string) string { } return fmt.Sprintf("%s:%s", name, tag) } - -// getAuthCreds returns authentication credentials resolver function from image reference domain and auth config. -func (s *service) getAuthCreds(refDomain string, ac dockertypes.AuthConfig) (dockerconfigresolver.AuthCreds, error) { - // return nil if no credentials specified - if ac.Username == "" && ac.Password == "" && ac.IdentityToken == "" && ac.RegistryToken == "" { - return nil, nil - } - - // domain expected by the authcreds function - // DefaultHost converts "docker.io" to "registry-1.docker.io" - expectedDomain, err := s.client.DefaultDockerHost(refDomain) - if err != nil { - return nil, err - } - - // ensure that server address matches the image reference domain - sa := ac.ServerAddress - if sa != "" { - saHostname := convertToHostname(sa) - // "registry-1.docker.io" can show up as "https://index.docker.io/v1/" in ServerAddress - if expectedDomain == "registry-1.docker.io" { - if saHostname != refDomain && sa != dockerconfigresolver.IndexServer { - return nil, fmt.Errorf("specified server address %s does not match the image reference domain %s", sa, refDomain) - } - } else if saHostname != refDomain { - return nil, fmt.Errorf("specified server address %s does not match the image reference domain %s", sa, refDomain) - } - } - - // return auth creds function - return func(domain string) (string, string, error) { - if domain != expectedDomain { - return "", "", fmt.Errorf("expected domain %s, but got %s", expectedDomain, domain) - } - if ac.IdentityToken != "" { - return "", ac.IdentityToken, nil - } else { - return ac.Username, ac.Password, nil - } - }, nil -} - -// convertToHostname converts a registry url which has http|https prepended -// to just an hostname. -// Copied from github.com/docker/docker/registry.ConvertToHostname to reduce dependencies. -func convertToHostname(url string) string { - stripped := url - if strings.HasPrefix(url, "http://") { - stripped = strings.TrimPrefix(url, "http://") - } else if strings.HasPrefix(url, "https://") { - stripped = strings.TrimPrefix(url, "https://") - } - - hostName, _, _ := strings.Cut(stripped, "/") - return hostName -} diff --git a/internal/service/image/push.go b/internal/service/image/push.go index 05112f1..5b7f9b2 100644 --- a/internal/service/image/push.go +++ b/internal/service/image/push.go @@ -15,11 +15,12 @@ import ( "github.com/runfinch/finch-daemon/api/types" "github.com/runfinch/finch-daemon/pkg/errdefs" + "github.com/runfinch/finch-daemon/pkg/utility/imageutility" ) func (s *service) Push(ctx context.Context, name, tag string, ac *dockertypes.AuthConfig, outStream io.Writer) (*types.PushResult, error) { // Canonicalize and parse raw image reference as "image:tag" or "image@digest" - rawRef, err := canonicalize(name, tag) + rawRef, err := imageutility.Canonicalize(name, tag) if err != nil { return nil, errdefs.NewInvalidFormat(fmt.Errorf("failed to canonicalize the ref: %w", err)) } @@ -47,7 +48,7 @@ func (s *service) Push(ctx context.Context, name, tag string, ac *dockertypes.Au // Get auth creds and the corresponding docker remotes resolver var creds dockerconfigresolver.AuthCreds if ac != nil { - creds, err = getAuthCredsFunc(s, refDomain, *ac) + creds, err = getAuthCredsFunc(refDomain, s.client, *ac) if err != nil { return nil, err } diff --git a/mocks/mocks_distribution/distributionsvc.go b/mocks/mocks_distribution/distributionsvc.go new file mode 100644 index 0000000..9858d32 --- /dev/null +++ b/mocks/mocks_distribution/distributionsvc.go @@ -0,0 +1,52 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/runfinch/finch-daemon/api/handlers/distribution (interfaces: Service) + +// Package mocks_distribution is a generated GoMock package. +package mocks_distribution + +import ( + context "context" + reflect "reflect" + + types "github.com/docker/cli/cli/config/types" + registry "github.com/docker/docker/api/types/registry" + gomock "github.com/golang/mock/gomock" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// Inspect mocks base method. +func (m *MockService) Inspect(arg0 context.Context, arg1 string, arg2 *types.AuthConfig) (*registry.DistributionInspect, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Inspect", arg0, arg1, arg2) + ret0, _ := ret[0].(*registry.DistributionInspect) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Inspect indicates an expected call of Inspect. +func (mr *MockServiceMockRecorder) Inspect(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inspect", reflect.TypeOf((*MockService)(nil).Inspect), arg0, arg1, arg2) +} diff --git a/mocks/mocks_remotes/fetcher.go b/mocks/mocks_remotes/fetcher.go new file mode 100644 index 0000000..1ce7bbe --- /dev/null +++ b/mocks/mocks_remotes/fetcher.go @@ -0,0 +1,52 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/containerd/containerd/remotes (interfaces: Fetcher) + +// Package mocks_remotes is a generated GoMock package. +package mocks_remotes + +import ( + context "context" + io "io" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "github.com/opencontainers/image-spec/specs-go/v1" +) + +// MockFetcher is a mock of Fetcher interface. +type MockFetcher struct { + ctrl *gomock.Controller + recorder *MockFetcherMockRecorder +} + +// MockFetcherMockRecorder is the mock recorder for MockFetcher. +type MockFetcherMockRecorder struct { + mock *MockFetcher +} + +// NewMockFetcher creates a new mock instance. +func NewMockFetcher(ctrl *gomock.Controller) *MockFetcher { + mock := &MockFetcher{ctrl: ctrl} + mock.recorder = &MockFetcherMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFetcher) EXPECT() *MockFetcherMockRecorder { + return m.recorder +} + +// Fetch mocks base method. +func (m *MockFetcher) Fetch(arg0 context.Context, arg1 v1.Descriptor) (io.ReadCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Fetch", arg0, arg1) + ret0, _ := ret[0].(io.ReadCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Fetch indicates an expected call of Fetch. +func (mr *MockFetcherMockRecorder) Fetch(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fetch", reflect.TypeOf((*MockFetcher)(nil).Fetch), arg0, arg1) +} diff --git a/mocks/mocks_remotes/resolver.go b/mocks/mocks_remotes/resolver.go new file mode 100644 index 0000000..8b5b28e --- /dev/null +++ b/mocks/mocks_remotes/resolver.go @@ -0,0 +1,83 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/containerd/containerd/remotes (interfaces: Resolver) + +// Package mocks_remotes is a generated GoMock package. +package mocks_remotes + +import ( + context "context" + reflect "reflect" + + remotes "github.com/containerd/containerd/remotes" + gomock "github.com/golang/mock/gomock" + v1 "github.com/opencontainers/image-spec/specs-go/v1" +) + +// MockResolver is a mock of Resolver interface. +type MockResolver struct { + ctrl *gomock.Controller + recorder *MockResolverMockRecorder +} + +// MockResolverMockRecorder is the mock recorder for MockResolver. +type MockResolverMockRecorder struct { + mock *MockResolver +} + +// NewMockResolver creates a new mock instance. +func NewMockResolver(ctrl *gomock.Controller) *MockResolver { + mock := &MockResolver{ctrl: ctrl} + mock.recorder = &MockResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockResolver) EXPECT() *MockResolverMockRecorder { + return m.recorder +} + +// Fetcher mocks base method. +func (m *MockResolver) Fetcher(arg0 context.Context, arg1 string) (remotes.Fetcher, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Fetcher", arg0, arg1) + ret0, _ := ret[0].(remotes.Fetcher) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Fetcher indicates an expected call of Fetcher. +func (mr *MockResolverMockRecorder) Fetcher(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fetcher", reflect.TypeOf((*MockResolver)(nil).Fetcher), arg0, arg1) +} + +// Pusher mocks base method. +func (m *MockResolver) Pusher(arg0 context.Context, arg1 string) (remotes.Pusher, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Pusher", arg0, arg1) + ret0, _ := ret[0].(remotes.Pusher) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Pusher indicates an expected call of Pusher. +func (mr *MockResolverMockRecorder) Pusher(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pusher", reflect.TypeOf((*MockResolver)(nil).Pusher), arg0, arg1) +} + +// Resolve mocks base method. +func (m *MockResolver) Resolve(arg0 context.Context, arg1 string) (string, v1.Descriptor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", arg0, arg1) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(v1.Descriptor) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockResolverMockRecorder) Resolve(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockResolver)(nil).Resolve), arg0, arg1) +} diff --git a/pkg/utility/authutility/utility.go b/pkg/utility/authutility/utility.go new file mode 100644 index 0000000..b6e67a1 --- /dev/null +++ b/pkg/utility/authutility/utility.go @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package authutility + +import ( + "fmt" + "strings" + + dockertypes "github.com/docker/cli/cli/config/types" + + "github.com/containerd/nerdctl/pkg/imgutil/dockerconfigresolver" + "github.com/runfinch/finch-daemon/internal/backend" +) + +// GetAuthCreds returns authentication credentials resolver function from image reference domain and auth config. +func GetAuthCreds(refDomain string, containerdClient backend.ContainerdClient, ac dockertypes.AuthConfig) (dockerconfigresolver.AuthCreds, error) { + // return nil if no credentials specified + if ac.Username == "" && ac.Password == "" && ac.IdentityToken == "" && ac.RegistryToken == "" { + return nil, nil + } + + // domain expected by the authcreds function + // DefaultHost converts "docker.io" to "registry-1.docker.io" + expectedDomain, err := containerdClient.DefaultDockerHost(refDomain) + if err != nil { + return nil, err + } + + // ensure that server address matches the image reference domain + sa := ac.ServerAddress + if sa != "" { + saHostname := convertToHostname(sa) + // "registry-1.docker.io" can show up as "https://index.docker.io/v1/" in ServerAddress + if expectedDomain == "registry-1.docker.io" { + if saHostname != refDomain && sa != dockerconfigresolver.IndexServer { + return nil, fmt.Errorf("specified server address %s does not match the image reference domain %s", sa, refDomain) + } + } else if saHostname != refDomain { + return nil, fmt.Errorf("specified server address %s does not match the image reference domain %s", sa, refDomain) + } + } + + // return auth creds function + return func(domain string) (string, string, error) { + if domain != expectedDomain { + return "", "", fmt.Errorf("expected domain %s, but got %s", expectedDomain, domain) + } + if ac.IdentityToken != "" { + return "", ac.IdentityToken, nil + } else { + return ac.Username, ac.Password, nil + } + }, nil +} + +// convertToHostname converts a registry url which has http|https prepended +// to just an hostname. +// Copied from github.com/docker/docker/registry.ConvertToHostname to reduce dependencies. +func convertToHostname(url string) string { + stripped := url + if strings.HasPrefix(url, "http://") { + stripped = strings.TrimPrefix(url, "http://") + } else if strings.HasPrefix(url, "https://") { + stripped = strings.TrimPrefix(url, "https://") + } + + hostName, _, _ := strings.Cut(stripped, "/") + return hostName +} diff --git a/pkg/utility/imageutility/utility.go b/pkg/utility/imageutility/utility.go new file mode 100644 index 0000000..dfb5dd3 --- /dev/null +++ b/pkg/utility/imageutility/utility.go @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package imageutility + +import ( + "strings" + + "github.com/distribution/reference" +) + +const ( + defaultTag = "latest" + tagDigestPrefix = "sha256:" + eventType = "image" +) + +func Canonicalize(name, tag string) (string, error) { + if name != "" { + if strings.HasPrefix(tag, tagDigestPrefix) { + name += "@" + tag + } else if tag != "" { + name += ":" + tag + } + } else { + name = tag + } + ref, err := reference.ParseAnyReference(name) + if err != nil { + return "", err + } + if named, ok := ref.(reference.Named); ok && refNeedsTag(ref) { + tagged, err := reference.WithTag(named, defaultTag) + if err == nil { + ref = tagged + } + } + return ref.String(), nil +} + +func refNeedsTag(ref reference.Reference) bool { + _, tagged := ref.(reference.Tagged) + _, digested := ref.(reference.Digested) + return !(tagged || digested) +}