From 0e413d7a3833f2b392921bf7131e80bf6b969fa0 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 13 Nov 2024 14:02:08 -0500 Subject: [PATCH] feat: add distribution API (#92) * feat: add distribution API Signed-off-by: Justin Alvarez * fix non-manifestlist case Signed-off-by: Justin Alvarez --------- Signed-off-by: Justin Alvarez --- api/handlers/distribution/distribution.go | 77 +++ .../distribution/distribution_test.go | 122 +++++ api/router/router.go | 19 +- cmd/finch-daemon/router_utils.go | 20 +- go.mod | 14 +- go.sum | 28 +- internal/service/distribution/distribution.go | 166 +++++++ .../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 ++ 17 files changed, 1182 insertions(+), 130 deletions(-) create mode 100644 api/handlers/distribution/distribution.go create mode 100644 api/handlers/distribution/distribution_test.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..4670a29 --- /dev/null +++ b/api/handlers/distribution/distribution.go @@ -0,0 +1,77 @@ +// 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 + switch { + case errdefs.IsInvalidFormat(err): + code = http.StatusBadRequest + case errdefs.IsForbiddenError(err): + code = http.StatusForbidden + case errdefs.IsNotFound(err): + code = http.StatusNotFound + 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/go.mod b/go.mod index 1a131ca..6f320ce 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/moby/moby v26.0.0+incompatible github.com/onsi/ginkgo/v2 v2.20.2 - github.com/onsi/gomega v1.34.2 + github.com/onsi/gomega v1.35.1 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.0 github.com/opencontainers/runtime-spec v1.2.0 @@ -40,9 +40,9 @@ require ( github.com/stretchr/testify v1.9.0 github.com/vishvananda/netlink v1.3.0 github.com/vishvananda/netns v0.0.4 - golang.org/x/net v0.29.0 - golang.org/x/sys v0.25.0 - google.golang.org/protobuf v1.34.2 + golang.org/x/net v0.30.0 + golang.org/x/sys v0.26.0 + google.golang.org/protobuf v1.35.1 ) require ( @@ -143,11 +143,11 @@ require ( go.opentelemetry.io/otel v1.30.0 // indirect go.opentelemetry.io/otel/metric v1.30.0 // indirect go.opentelemetry.io/otel/trace v1.30.0 // indirect - golang.org/x/crypto v0.27.0 // indirect + golang.org/x/crypto v0.28.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/term v0.24.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/term v0.25.0 // indirect + golang.org/x/text v0.19.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.24.0 // indirect google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect diff --git a/go.sum b/go.sum index 6217b41..822d9be 100644 --- a/go.sum +++ b/go.sum @@ -252,8 +252,8 @@ github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/n github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4= github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag= -github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8= -github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -349,8 +349,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= @@ -376,8 +376,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -409,23 +409,23 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= -golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -472,8 +472,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/service/distribution/distribution.go b/internal/service/distribution/distribution.go new file mode 100644 index 0000000..604a205 --- /dev/null +++ b/internal/service/distribution/distribution.go @@ -0,0 +1,166 @@ +// 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" + 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.NewForbidden(err) + default: + 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) +}