From 49e8167dbee7de687c5113b6daf44fb5f875eea6 Mon Sep 17 00:00:00 2001 From: Alex Stan Date: Tue, 16 Aug 2022 11:57:09 +0300 Subject: [PATCH] graphql: Apply authorization on /_search endpoint - AccessControlContext now resides in a separate package from where it can be imported, along with the contextKey that will be used to set and retrieve this context value. - AccessControlContext has a new field called Username, that will be of use for future implementations in graphQL resolvers. - GlobalSearch resolver now uses this context to filter repos available to the logged user. - moved logic for uploading images in tests so that it can be used in every package - tests were added for multiple request scenarios, when zot-server requires authz on specific repos - added tests with injected errors for extended coverage - added tests for status code error injection utilities Closes https://github.com/project-zot/zot/issues/615 Signed-off-by: Alex Stan --- examples/config-tls.json | 4 +- pkg/api/authz.go | 35 +-- pkg/api/controller_test.go | 160 ++++++++++- pkg/api/routes.go | 7 +- pkg/extensions/extension_search.go | 4 +- pkg/extensions/search/common/common_test.go | 140 +--------- pkg/extensions/search/resolver.go | 53 +++- pkg/extensions/search/resolver_test.go | 28 ++ pkg/extensions/search/schema.resolvers.go | 9 +- pkg/requestcontext/context.go | 18 ++ pkg/test/common.go | 139 ++++++++++ pkg/test/common_test.go | 284 ++++++++++++++++++++ pkg/test/dev.go | 15 ++ pkg/test/inject_test.go | 28 ++ pkg/test/prod.go | 4 + 15 files changed, 763 insertions(+), 165 deletions(-) create mode 100644 pkg/requestcontext/context.go diff --git a/examples/config-tls.json b/examples/config-tls.json index 6ebfa697..901a166b 100644 --- a/examples/config-tls.json +++ b/examples/config-tls.json @@ -8,8 +8,8 @@ "port": "8080", "realm": "zot", "tls": { - "cert": "test/data/server.cert", - "key": "test/data/server.key" + "cert": "../../test/data/server.cert", + "key": "../../test/data/server.key" } }, "log": { diff --git a/pkg/api/authz.go b/pkg/api/authz.go index 8bd5a24d..aa8b9011 100644 --- a/pkg/api/authz.go +++ b/pkg/api/authz.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" "time" glob "github.com/bmatcuk/doublestar/v4" @@ -12,19 +13,15 @@ import ( "zotregistry.io/zot/pkg/api/constants" "zotregistry.io/zot/pkg/common" "zotregistry.io/zot/pkg/log" + localCtx "zotregistry.io/zot/pkg/requestcontext" ) -type contextKey int - const ( // actions. CREATE = "create" READ = "read" UPDATE = "update" DELETE = "delete" - - // request-local context key. - authzCtxKey contextKey = 0 ) // AccessController authorizes users to act on resources. @@ -33,12 +30,6 @@ type AccessController struct { Log log.Logger } -// AccessControlContext context passed down to http.Handlers. -type AccessControlContext struct { - globPatterns map[string]bool - isAdmin bool -} - func NewAccessController(config *config.Config) *AccessController { return &AccessController{ Config: config.AccessControl, @@ -111,14 +102,18 @@ func (ac *AccessController) isAdmin(username string) bool { // getContext builds ac context(allowed to read repos and if user is admin) and returns it. func (ac *AccessController) getContext(username string, request *http.Request) context.Context { readGlobPatterns := ac.getReadGlobPatterns(username) - acCtx := AccessControlContext{globPatterns: readGlobPatterns} - - if ac.isAdmin(username) { - acCtx.isAdmin = true - } else { - acCtx.isAdmin = false + acCtx := localCtx.AccessControlContext{ + GlobPatterns: readGlobPatterns, + Username: username, } + if ac.isAdmin(username) { + acCtx.IsAdmin = true + } else { + acCtx.IsAdmin = false + } + + authzCtxKey := localCtx.GetContextKey() ctx := context.WithValue(request.Context(), authzCtxKey, acCtx) return ctx @@ -227,6 +222,12 @@ func AuthzHandler(ctlr *Controller) mux.MiddlewareFunc { return } + if strings.Contains(request.RequestURI, constants.ExtSearchPrefix) { + next.ServeHTTP(response, request.WithContext(ctx)) + + return + } + var action string if request.Method == http.MethodGet || request.Method == http.MethodHead { action = READ diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index 7c26da7d..91e3d799 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -317,7 +317,6 @@ func TestHtpasswdTwoCreds(t *testing.T) { } ctlr := api.NewController(conf) ctlr.Config.Storage.RootDirectory = t.TempDir() - go startServer(ctlr) defer stopServer(ctlr) test.WaitTillServerReady(baseURL) @@ -5701,7 +5700,7 @@ func TestInjectTooManyOpenFiles(t *testing.T) { func TestPeriodicGC(t *testing.T) { Convey("Periodic gc enabled for default store", t, func() { - repoName := "test" + repoName := "testRepo" port := test.GetFreePort() baseURL := test.GetBaseURL(port) @@ -5858,6 +5857,163 @@ func TestPeriodicTasks(t *testing.T) { }) } +func TestSearchRoutes(t *testing.T) { + Convey("Upload image for test", t, func(c C) { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + tempDir := t.TempDir() + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = tempDir + + go startServer(ctlr) + defer stopServer(ctlr) + + test.WaitTillServerReady(baseURL) + + repoName := "testrepo" + inaccessibleRepo := "inaccessible" + cfg, layers, manifest, err := test.GetImageComponents(10000) + So(err, ShouldBeNil) + + err = test.UploadImage( + test.Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + Tag: "latest", + }, baseURL, repoName) + + So(err, ShouldBeNil) + + // data for the inaccessible repo + cfg, layers, manifest, err = test.GetImageComponents(10000) + So(err, ShouldBeNil) + + err = test.UploadImage( + test.Image{ + Config: cfg, + Layers: layers, + Manifest: manifest, + Tag: "latest", + }, baseURL, inaccessibleRepo) + + So(err, ShouldBeNil) + + Convey("GlobalSearch with authz enabled", func(c C) { + conf := config.New() + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + user1 := "test" + password1 := "test" + testString1 := getCredString(user1, password1) + htpasswdPath := test.MakeHtpasswdFileFromString(testString1) + defer os.Remove(htpasswdPath) + conf.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath, + }, + } + + conf.HTTP.Port = port + + defaultVal := true + + searchConfig := &extconf.SearchConfig{ + Enable: &defaultVal, + } + + conf.Extensions = &extconf.ExtensionConfig{ + Search: searchConfig, + } + + conf.AccessControl = &config.AccessControlConfig{ + Repositories: config.Repositories{ + repoName: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{user1}, + Actions: []string{"read"}, + }, + }, + DefaultPolicy: []string{}, + }, + inaccessibleRepo: config.PolicyGroup{ + Policies: []config.Policy{ + { + Users: []string{}, + Actions: []string{}, + }, + }, + DefaultPolicy: []string{}, + }, + }, + AdminPolicy: config.Policy{ + Users: []string{}, + Actions: []string{}, + }, + } + + ctlr := api.NewController(conf) + + ctlr.Config.Storage.RootDirectory = tempDir + + go startServer(ctlr) + defer stopServer(ctlr) + test.WaitTillServerReady(baseURL) + + query := ` + { + GlobalSearch(query:""){ + Repos { + Name + Score + NewestImage { + RepoName + Tag + } + } + } + }` + resp, err := resty.R().SetBasicAuth(user1, password1).Get(baseURL + constants.ExtSearchPrefix + + "?query=" + url.QueryEscape(query)) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + + So(string(resp.Body()), ShouldContainSubstring, repoName) + So(string(resp.Body()), ShouldNotContainSubstring, inaccessibleRepo) + + resp, err = resty.R().Get(baseURL + constants.ExtSearchPrefix + "?query=" + url.QueryEscape(query)) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + + // credentials for user unauthorized to access repo + user2 := "notWorking" + password2 := "notWorking" + testString2 := getCredString(user2, password2) + htpasswdPath2 := test.MakeHtpasswdFileFromString(testString2) + defer os.Remove(htpasswdPath2) + + ctlr.Config.HTTP.Auth = &config.AuthConfig{ + HTPasswd: config.AuthHTPasswd{ + Path: htpasswdPath2, + }, + } + // authenticated, but no access to resource + resp, err = resty.R().SetBasicAuth(user2, password2).Get(baseURL + constants.ExtSearchPrefix + + "?query=" + url.QueryEscape(query)) + So(err, ShouldBeNil) + So(resp, ShouldNotBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusUnauthorized) + }) + }) +} + func TestDistSpecExtensions(t *testing.T) { Convey("start zot server with search extension", t, func(c C) { conf := config.New() diff --git a/pkg/api/routes.go b/pkg/api/routes.go index 06ba5b3a..55fe660e 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -34,6 +34,7 @@ import ( "zotregistry.io/zot/pkg/api/constants" ext "zotregistry.io/zot/pkg/extensions" "zotregistry.io/zot/pkg/log" + localCtx "zotregistry.io/zot/pkg/requestcontext" "zotregistry.io/zot/pkg/storage" "zotregistry.io/zot/pkg/test" // nolint:goimports // as required by swaggo. @@ -1240,9 +1241,11 @@ func (rh *RouteHandler) ListRepositories(response http.ResponseWriter, request * } var repos []string + authzCtxKey := localCtx.GetContextKey() + // get passed context from authzHandler and filter out repos based on permissions if authCtx := request.Context().Value(authzCtxKey); authCtx != nil { - acCtx, ok := authCtx.(AccessControlContext) + acCtx, ok := authCtx.(localCtx.AccessControlContext) if !ok { response.WriteHeader(http.StatusInternalServerError) @@ -1250,7 +1253,7 @@ func (rh *RouteHandler) ListRepositories(response http.ResponseWriter, request * } for _, r := range combineRepoList { - if acCtx.isAdmin || matchesRepo(acCtx.globPatterns, r) { + if acCtx.IsAdmin || matchesRepo(acCtx.GlobPatterns, r) { repos = append(repos, r) } } diff --git a/pkg/extensions/extension_search.go b/pkg/extensions/extension_search.go index 93c0bde8..6aa0005b 100644 --- a/pkg/extensions/extension_search.go +++ b/pkg/extensions/extension_search.go @@ -71,8 +71,8 @@ func SetupSearchRoutes(config *config.Config, router *mux.Router, storeControlle resConfig = search.GetResolverConfig(log, storeController, false) } - router.PathPrefix(constants.ExtSearchPrefix).Methods("OPTIONS", "GET", "POST"). - Handler(gqlHandler.NewDefaultServer(gql_generated.NewExecutableSchema(resConfig))) + graphqlPrefix := router.PathPrefix(constants.ExtSearchPrefix).Methods("OPTIONS", "GET", "POST") + graphqlPrefix.Handler(gqlHandler.NewDefaultServer(gql_generated.NewExecutableSchema(resConfig))) } } diff --git a/pkg/extensions/search/common/common_test.go b/pkg/extensions/search/common/common_test.go index 0dcfd044..5cf07bb3 100644 --- a/pkg/extensions/search/common/common_test.go +++ b/pkg/extensions/search/common/common_test.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "io/ioutil" - "net/http" "net/url" "os" "os/exec" @@ -19,7 +18,6 @@ import ( "time" "github.com/opencontainers/go-digest" - "github.com/opencontainers/image-spec/specs-go" ispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sigstore/cosign/cmd/cosign/cli/generate" "github.com/sigstore/cosign/cmd/cosign/cli/options" @@ -44,8 +42,6 @@ const ( var ( ErrTestError = errors.New("test error") - ErrPutBlob = errors.New("can't put blob") - ErrPostBlob = errors.New("can't post blob") ErrPutManifest = errors.New("can't put manifest") ) @@ -1043,7 +1039,7 @@ func TestSearchSize(t *testing.T) { WaitTillServerReady(baseURL) repoName := "testrepo" - config, layers, manifest, err := getImageComponents(10000) + config, layers, manifest, err := GetImageComponents(10000) So(err, ShouldBeNil) configBlob, err := json.Marshal(config) @@ -1060,7 +1056,7 @@ func TestSearchSize(t *testing.T) { manifestSize := len(manifestBlob) err = UploadImage( - uploadImage{ + Image{ Manifest: manifest, Config: config, Layers: layers, @@ -1107,7 +1103,7 @@ func TestSearchSize(t *testing.T) { // add the same image with different tag err = UploadImage( - uploadImage{ + Image{ Manifest: manifest, Config: config, Layers: layers, @@ -1135,136 +1131,6 @@ func TestSearchSize(t *testing.T) { }) } -func getImageComponents(layerSize int) (ispec.Image, [][]byte, ispec.Manifest, error) { - config := ispec.Image{ - Architecture: "amd64", - OS: "linux", - RootFS: ispec.RootFS{ - Type: "layers", - DiffIDs: []digest.Digest{}, - }, - Author: "ZotUser", - } - - configBlob, err := json.Marshal(config) - if err != nil { - return ispec.Image{}, [][]byte{}, ispec.Manifest{}, err - } - - configDigest := digest.FromBytes(configBlob) - - layers := [][]byte{ - make([]byte, layerSize), - } - - manifest := ispec.Manifest{ - Versioned: specs.Versioned{ - SchemaVersion: 2, - }, - Config: ispec.Descriptor{ - MediaType: "application/vnd.oci.image.config.v1+json", - Digest: configDigest, - Size: int64(len(configBlob)), - }, - Layers: []ispec.Descriptor{ - { - MediaType: "application/vnd.oci.image.layer.v1.tar", - Digest: digest.FromBytes(layers[0]), - Size: int64(len(layers[0])), - }, - }, - } - - return config, layers, manifest, nil -} - -type uploadImage struct { - Manifest ispec.Manifest - Config ispec.Image - Layers [][]byte - Tag string -} - -func UploadImage(img uploadImage, baseURL, repo string) error { - for _, blob := range img.Layers { - resp, err := resty.R().Post(baseURL + "/v2/" + repo + "/blobs/uploads/") - if err != nil { - return err - } - - if resp.StatusCode() != http.StatusAccepted { - return ErrPostBlob - } - - loc := resp.Header().Get("Location") - - digest := digest.FromBytes(blob).String() - - resp, err = resty.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", digest). - SetBody(blob). - Put(baseURL + loc) - - if resp.StatusCode() != http.StatusCreated { - return ErrPutBlob - } - - if err != nil { - return err - } - } - - // upload config - cblob, err := json.Marshal(img.Config) - if err != nil { - return err - } - - cdigest := digest.FromBytes(cblob) - - resp, err := resty.R(). - Post(baseURL + "/v2/" + repo + "/blobs/uploads/") - if err != nil { - return err - } - - if resp.StatusCode() != http.StatusAccepted { - return ErrPostBlob - } - - loc := Location(baseURL, resp) - - // uploading blob should get 201 - resp, err = resty.R(). - SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). - SetHeader("Content-Type", "application/octet-stream"). - SetQueryParam("digest", cdigest.String()). - SetBody(cblob). - Put(loc) - if err != nil { - return err - } - - if resp.StatusCode() != http.StatusCreated { - return ErrPutBlob - } - - // put manifest - manifestBlob, err := json.Marshal(img.Manifest) - if err != nil { - return err - } - - _, err = resty.R(). - SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). - SetBody(manifestBlob). - Put(baseURL + "/v2/" + repo + "/manifests/" + img.Tag) - - return err -} - func startServer(c *api.Controller) { // this blocks ctx := context.Background() diff --git a/pkg/extensions/search/resolver.go b/pkg/extensions/search/resolver.go index 22609ab1..157f2ebf 100644 --- a/pkg/extensions/search/resolver.go +++ b/pkg/extensions/search/resolver.go @@ -5,19 +5,22 @@ package search // It serves as dependency injection for your app, add any dependencies you require here. import ( + "context" + "errors" "sort" "strconv" "strings" + glob "github.com/bmatcuk/doublestar/v4" v1 "github.com/google/go-containerregistry/pkg/v1" godigest "github.com/opencontainers/go-digest" - "zotregistry.io/zot/pkg/log" // nolint: gci - ispec "github.com/opencontainers/image-spec/specs-go/v1" "zotregistry.io/zot/pkg/extensions/search/common" cveinfo "zotregistry.io/zot/pkg/extensions/search/cve" digestinfo "zotregistry.io/zot/pkg/extensions/search/digest" "zotregistry.io/zot/pkg/extensions/search/gql_generated" + "zotregistry.io/zot/pkg/log" // nolint: gci + localCtx "zotregistry.io/zot/pkg/requestcontext" "zotregistry.io/zot/pkg/storage" ) // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES. @@ -36,6 +39,8 @@ type cveDetail struct { PackageList []*gql_generated.PackageInfo } +var ErrBadCtxFormat = errors.New("type assertion failed") + // GetResolverConfig ... func GetResolverConfig(log log.Logger, storeController storage.StoreController, enableCVE bool) gql_generated.Config { var cveInfo *cveinfo.CveInfo @@ -469,3 +474,47 @@ func buildImageInfo(repo string, tag string, tagDigest godigest.Digest, return imageInfo } + +// returns either a user has or not rights on 'repository'. +func matchesRepo(globPatterns map[string]bool, repository string) bool { + var longestMatchedPattern string + + // because of the longest path matching rule, we need to check all patterns from config + for pattern := range globPatterns { + matched, err := glob.Match(pattern, repository) + if err == nil { + if matched && len(pattern) > len(longestMatchedPattern) { + longestMatchedPattern = pattern + } + } + } + + allowed := globPatterns[longestMatchedPattern] + + return allowed +} + +// get passed context from authzHandler and filter out repos based on permissions. +func userAvailableRepos(ctx context.Context, repoList []string) ([]string, error) { + var availableRepos []string + + authzCtxKey := localCtx.GetContextKey() + if authCtx := ctx.Value(authzCtxKey); authCtx != nil { + acCtx, ok := authCtx.(localCtx.AccessControlContext) + if !ok { + err := ErrBadCtxFormat + + return []string{}, err + } + + for _, r := range repoList { + if acCtx.IsAdmin || matchesRepo(acCtx.GlobPatterns, r) { + availableRepos = append(availableRepos, r) + } + } + } else { + availableRepos = repoList + } + + return availableRepos, nil +} diff --git a/pkg/extensions/search/resolver_test.go b/pkg/extensions/search/resolver_test.go index 38e237fd..6f12c3e7 100644 --- a/pkg/extensions/search/resolver_test.go +++ b/pkg/extensions/search/resolver_test.go @@ -1,16 +1,22 @@ package search //nolint import ( + "context" "errors" + "os" "strings" "testing" v1 "github.com/google/go-containerregistry/pkg/v1" godigest "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/rs/zerolog" . "github.com/smartystreets/goconvey/convey" + "zotregistry.io/zot/pkg/extensions/monitoring" "zotregistry.io/zot/pkg/extensions/search/common" "zotregistry.io/zot/pkg/log" + localCtx "zotregistry.io/zot/pkg/requestcontext" + "zotregistry.io/zot/pkg/storage" "zotregistry.io/zot/pkg/test/mocks" ) @@ -172,6 +178,28 @@ func TestGlobalSearch(t *testing.T) { }) } +func TestUserAvailableRepos(t *testing.T) { + Convey("Type assertion fails", t, func() { + var invalid struct{} + + log := log.Logger{Logger: zerolog.New(os.Stdout)} + dir := t.TempDir() + metrics := monitoring.NewMetricsServer(false, log) + defaultStore := storage.NewImageStore(dir, false, 0, false, false, log, metrics, nil) + + repoList, err := defaultStore.GetRepositories() + So(err, ShouldBeNil) + + ctx := context.TODO() + key := localCtx.GetContextKey() + ctx = context.WithValue(ctx, key, invalid) + + repos, err := userAvailableRepos(ctx, repoList) + So(err, ShouldNotBeNil) + So(repos, ShouldBeEmpty) + }) +} + func TestMatching(t *testing.T) { pine := "pine" diff --git a/pkg/extensions/search/schema.resolvers.go b/pkg/extensions/search/schema.resolvers.go index 5fc04cac..1c641a1f 100644 --- a/pkg/extensions/search/schema.resolvers.go +++ b/pkg/extensions/search/schema.resolvers.go @@ -454,7 +454,14 @@ func (r *queryResolver) GlobalSearch(ctx context.Context, query string) (*gql_ge return &gql_generated.GlobalSearchResult{}, err } - repos, images, layers := globalSearch(repoList, name, tag, olu, r.log) + availableRepos, err := userAvailableRepos(ctx, repoList) + if err != nil { + r.log.Error().Err(err).Msg("unable to filter user available repositories") + + return &gql_generated.GlobalSearchResult{}, err + } + + repos, images, layers := globalSearch(availableRepos, name, tag, olu, r.log) return &gql_generated.GlobalSearchResult{ Images: images, diff --git a/pkg/requestcontext/context.go b/pkg/requestcontext/context.go new file mode 100644 index 00000000..06aa0506 --- /dev/null +++ b/pkg/requestcontext/context.go @@ -0,0 +1,18 @@ +package requestcontext + +type Key int + +// request-local context key. +var authzCtxKey = Key(0) // nolint: gochecknoglobals + +// pointer needed for use in context.WithValue. +func GetContextKey() *Key { + return &authzCtxKey +} + +// AccessControlContext context passed down to http.Handlers. +type AccessControlContext struct { + GlobPatterns map[string]bool + IsAdmin bool + Username string +} diff --git a/pkg/test/common.go b/pkg/test/common.go index 955d40e5..ab675f99 100644 --- a/pkg/test/common.go +++ b/pkg/test/common.go @@ -4,17 +4,20 @@ import ( "context" "crypto/rand" "encoding/json" + "errors" "fmt" "io" "io/ioutil" "log" "math/big" + "net/http" "net/url" "os" "path" "time" godigest "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" imagespec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/opencontainers/umoci" "github.com/phayes/freeport" @@ -27,6 +30,18 @@ const ( SleepTime = 100 * time.Millisecond ) +var ( + ErrPostBlob = errors.New("can't post blob") + ErrPutBlob = errors.New("can't put blob") +) + +type Image struct { + Manifest imagespec.Manifest + Config imagespec.Image + Layers [][]byte + Tag string +} + func GetFreePort() string { port, err := freeport.GetFreePort() if err != nil { @@ -264,3 +279,127 @@ func GetOciLayoutDigests(imagePath string) (godigest.Digest, godigest.Digest, go return manifestDigest, configDigest, layerDigest } + +func GetImageComponents(layerSize int) (imagespec.Image, [][]byte, imagespec.Manifest, error) { + config := imagespec.Image{ + Architecture: "amd64", + OS: "linux", + RootFS: imagespec.RootFS{ + Type: "layers", + DiffIDs: []godigest.Digest{}, + }, + Author: "ZotUser", + } + + configBlob, err := json.Marshal(config) + if err = Error(err); err != nil { + return imagespec.Image{}, [][]byte{}, imagespec.Manifest{}, err + } + + configDigest := godigest.FromBytes(configBlob) + + layers := [][]byte{ + make([]byte, layerSize), + } + + schemaVersion := 2 + + manifest := imagespec.Manifest{ + Versioned: specs.Versioned{ + SchemaVersion: schemaVersion, + }, + Config: imagespec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: configDigest, + Size: int64(len(configBlob)), + }, + Layers: []imagespec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: godigest.FromBytes(layers[0]), + Size: int64(len(layers[0])), + }, + }, + } + + return config, layers, manifest, nil +} + +func UploadImage(img Image, baseURL, repo string) error { + for _, blob := range img.Layers { + resp, err := resty.R().Post(baseURL + "/v2/" + repo + "/blobs/uploads/") + if err != nil { + return err + } + + if resp.StatusCode() != http.StatusAccepted { + return ErrPostBlob + } + + loc := resp.Header().Get("Location") + + digest := godigest.FromBytes(blob).String() + + resp, err = resty.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(blob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", digest). + SetBody(blob). + Put(baseURL + loc) + + if err != nil { + return err + } + + if resp.StatusCode() != http.StatusCreated { + return ErrPutBlob + } + } + // upload config + cblob, err := json.Marshal(img.Config) + if err = Error(err); err != nil { + return err + } + + cdigest := godigest.FromBytes(cblob) + + resp, err := resty.R(). + Post(baseURL + "/v2/" + repo + "/blobs/uploads/") + if err = Error(err); err != nil { + return err + } + + if ErrStatusCode(resp.StatusCode()) != http.StatusAccepted && ErrStatusCode(resp.StatusCode()) == -1 { + return ErrPostBlob + } + + loc := Location(baseURL, resp) + + // uploading blob should get 201 + resp, err = resty.R(). + SetHeader("Content-Length", fmt.Sprintf("%d", len(cblob))). + SetHeader("Content-Type", "application/octet-stream"). + SetQueryParam("digest", cdigest.String()). + SetBody(cblob). + Put(loc) + if err = Error(err); err != nil { + return err + } + + if ErrStatusCode(resp.StatusCode()) != http.StatusCreated && ErrStatusCode(resp.StatusCode()) == -1 { + return ErrPostBlob + } + + // put manifest + manifestBlob, err := json.Marshal(img.Manifest) + if err = Error(err); err != nil { + return err + } + + _, err = resty.R(). + SetHeader("Content-type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBlob). + Put(baseURL + "/v2/" + repo + "/manifests/" + img.Tag) + + return err +} diff --git a/pkg/test/common_test.go b/pkg/test/common_test.go index e95d164e..39ace4bd 100644 --- a/pkg/test/common_test.go +++ b/pkg/test/common_test.go @@ -4,14 +4,18 @@ package test_test import ( + "context" "encoding/json" "io/ioutil" "os" "path" "testing" + "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" . "github.com/smartystreets/goconvey/convey" + "zotregistry.io/zot/pkg/api" + "zotregistry.io/zot/pkg/api/config" "zotregistry.io/zot/pkg/test" ) @@ -122,3 +126,283 @@ func TestGetOciLayoutDigests(t *testing.T) { } }) } + +func TestGetImageComponents(t *testing.T) { + Convey("Inject failures for unreachable lines", t, func() { + injected := test.InjectFailure(0) + if injected { + _, _, _, err := test.GetImageComponents(100) + So(err, ShouldNotBeNil) + } + }) + Convey("finishes successfully", t, func() { + _, _, _, err := test.GetImageComponents(100) + So(err, ShouldBeNil) + }) +} + +func TestUploadImage(t *testing.T) { + Convey("Post request results in an error", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = t.TempDir() + + img := test.Image{ + Layers: make([][]byte, 10), + } + + err := test.UploadImage(img, baseURL, "test") + So(err, ShouldNotBeNil) + }) + + Convey("Post request status differs from accepted", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + tempDir := t.TempDir() + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = tempDir + + err := os.Chmod(tempDir, 0o400) + if err != nil { + t.Fatal(err) + } + + ctlr := api.NewController(conf) + go startServer(ctlr) + defer stopServer(ctlr) + + test.WaitTillServerReady(baseURL) + + img := test.Image{ + Layers: make([][]byte, 10), + } + + err = test.UploadImage(img, baseURL, "test") + So(err, ShouldNotBeNil) + }) + + Convey("Put request results in an error", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + go startServer(ctlr) + defer stopServer(ctlr) + + test.WaitTillServerReady(baseURL) + + img := test.Image{ + Layers: make([][]byte, 10), // invalid format that will result in an error + Config: ispec.Image{}, + } + + err := test.UploadImage(img, baseURL, "test") + So(err, ShouldNotBeNil) + }) + + Convey("Image uploaded successfully", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = t.TempDir() + + ctlr := api.NewController(conf) + go startServer(ctlr) + defer stopServer(ctlr) + + test.WaitTillServerReady(baseURL) + + layerBlob := []byte("test") + + img := test.Image{ + Layers: [][]byte{ + layerBlob, + }, // invalid format that will result in an error + Config: ispec.Image{}, + } + + err := test.UploadImage(img, baseURL, "test") + So(err, ShouldBeNil) + }) + + Convey("Blob upload wrong response status code", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + tempDir := t.TempDir() + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = tempDir + + ctlr := api.NewController(conf) + go startServer(ctlr) + defer stopServer(ctlr) + + test.WaitTillServerReady(baseURL) + + layerBlob := []byte("test") + layerBlobDigest := digest.FromBytes(layerBlob) + layerPath := path.Join(tempDir, "test", "blobs", "sha256") + + if _, err := os.Stat(layerPath); os.IsNotExist(err) { + err = os.MkdirAll(layerPath, 0o700) + if err != nil { + t.Fatal(err) + } + file, err := os.Create(path.Join(layerPath, layerBlobDigest.Encoded())) + if err != nil { + t.Fatal(err) + } + + err = os.Chmod(layerPath, 0o000) + if err != nil { + t.Fatal(err) + } + defer func() { + err = os.Chmod(layerPath, 0o700) + if err != nil { + t.Fatal(err) + } + os.RemoveAll(file.Name()) + }() + } + + img := test.Image{ + Layers: [][]byte{ + layerBlob, + }, // invalid format that will result in an error + Config: ispec.Image{}, + } + + err := test.UploadImage(img, baseURL, "test") + So(err, ShouldNotBeNil) + }) + + Convey("CreateBlobUpload wrong response status code", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + tempDir := t.TempDir() + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = tempDir + + ctlr := api.NewController(conf) + go startServer(ctlr) + defer stopServer(ctlr) + + test.WaitTillServerReady(baseURL) + + layerBlob := []byte("test") + + img := test.Image{ + Layers: [][]byte{ + layerBlob, + }, // invalid format that will result in an error + Config: ispec.Image{}, + } + + Convey("CreateBlobUpload", func() { + injected := test.InjectFailure(2) + if injected { + err := test.UploadImage(img, baseURL, "test") + So(err, ShouldNotBeNil) + } + }) + Convey("UpdateBlobUpload", func() { + injected := test.InjectFailure(4) + if injected { + err := test.UploadImage(img, baseURL, "test") + So(err, ShouldNotBeNil) + } + }) + }) +} + +func TestInjectUploadImage(t *testing.T) { + Convey("Inject failures for unreachable lines", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + + tempDir := t.TempDir() + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = tempDir + + ctlr := api.NewController(conf) + go startServer(ctlr) + defer stopServer(ctlr) + + test.WaitTillServerReady(baseURL) + + layerBlob := []byte("test") + layerPath := path.Join(tempDir, "test", ".uploads") + + if _, err := os.Stat(layerPath); os.IsNotExist(err) { + err = os.MkdirAll(layerPath, 0o700) + if err != nil { + t.Fatal(err) + } + } + + img := test.Image{ + Layers: [][]byte{ + layerBlob, + }, // invalid format that will result in an error + Config: ispec.Image{}, + } + + Convey("first marshal", func() { + injected := test.InjectFailure(0) + if injected { + err := test.UploadImage(img, baseURL, "test") + So(err, ShouldNotBeNil) + } + }) + Convey("CreateBlobUpload POST call", func() { + injected := test.InjectFailure(1) + if injected { + err := test.UploadImage(img, baseURL, "test") + So(err, ShouldNotBeNil) + } + }) + Convey("UpdateBlobUpload PUT call", func() { + injected := test.InjectFailure(3) + if injected { + err := test.UploadImage(img, baseURL, "test") + So(err, ShouldNotBeNil) + } + }) + Convey("second marshal", func() { + injected := test.InjectFailure(5) + if injected { + err := test.UploadImage(img, baseURL, "test") + So(err, ShouldNotBeNil) + } + }) + }) +} + +func startServer(c *api.Controller) { + // this blocks + ctx := context.Background() + if err := c.Run(ctx); err != nil { + return + } +} + +func stopServer(c *api.Controller) { + ctx := context.Background() + _ = c.Server.Shutdown(ctx) +} diff --git a/pkg/test/dev.go b/pkg/test/dev.go index 88693f05..853e29f1 100644 --- a/pkg/test/dev.go +++ b/pkg/test/dev.go @@ -6,6 +6,7 @@ package test import ( + "net/http" "sync" zerr "zotregistry.io/zot/errors" @@ -36,6 +37,20 @@ func Error(err error) error { return nil } +// Used to inject error status codes for coverage purposes. +// -1 will be returned in case of successful failure injection. +func ErrStatusCode(status int) int { + if !injectedFailure() { + if status == http.StatusAccepted || status == http.StatusCreated { + return status + } + + return 0 + } + + return -1 +} + /** * * Failure injection infrastructure to cover hard-to-reach code paths. diff --git a/pkg/test/inject_test.go b/pkg/test/inject_test.go index 36ce3af2..b8525a2c 100644 --- a/pkg/test/inject_test.go +++ b/pkg/test/inject_test.go @@ -59,6 +59,18 @@ func bar() error { return nil } +func baz() error { + if test.ErrStatusCode(0) != 0 { + return errCall1 + } + + if test.ErrStatusCode(0) != 0 { + return errCall2 + } + + return nil +} + func alwaysErr() error { return errNotZero } @@ -108,6 +120,22 @@ func TestInject(t *testing.T) { So(errors.Is(err, errCall2), ShouldBeTrue) }) }) + + Convey("Check ErrStatusCode", func() { + Convey("Without skipping", func() { + test.InjectFailure(0) // inject a failure + err := baz() // should be a failure + So(err, ShouldNotBeNil) // should be a failure + So(errors.Is(err, errCall1), ShouldBeTrue) + }) + + Convey("With skipping", func() { + test.InjectFailure(1) // inject a failure but skip first one + err := baz() // should be a failure + So(errors.Is(err, errCall1), ShouldBeFalse) + So(errors.Is(err, errCall2), ShouldBeTrue) + }) + }) }) Convey("Without injected failure", t, func(c C) { diff --git a/pkg/test/prod.go b/pkg/test/prod.go index f8c34425..dca8811d 100644 --- a/pkg/test/prod.go +++ b/pkg/test/prod.go @@ -11,6 +11,10 @@ func Ok(ok bool) bool { return ok } +func ErrStatusCode(statusCode int) int { + return statusCode +} + /** * * Failure injection infrastructure to cover hard-to-reach code paths (nop in production).