diff --git a/examples/README.md b/examples/README.md index 4c950688..5892ed4f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -91,6 +91,16 @@ Orphan blobs are removed if they are older than gcDelay. "gcDelay": "2h" ``` +To limit the maximum number of repositories that can be created, set: + +``` + "maxRepos": 10 +``` + +When the limit is reached, pushes that would create a new repository are +rejected with HTTP 429. Pushes to existing repositories are always allowed. +Setting maxRepos to 0 or omitting it disables enforcement. + It is also possible to store and serve images from multiple filesystems with their own repository paths, dedupe and garbage collection settings with: diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index b2b1db69..e0abe4e2 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -29,6 +29,7 @@ var ( type StorageConfig struct { RootDirectory string + MaxRepos int Dedupe bool RemoteCache bool GC bool @@ -1144,6 +1145,17 @@ func (c *Config) IsRetentionEnabled() bool { return c.isRetentionEnabledInternal() } +func (c *Config) IsQuotaEnabled() bool { + if c == nil { + return false + } + + c.mu.RLock() + defer c.mu.RUnlock() + + return c.Storage.MaxRepos > 0 +} + // IsCompatEnabled checks if compatibility mode is enabled. func (c *Config) IsCompatEnabled() bool { if c == nil { diff --git a/pkg/api/controller.go b/pkg/api/controller.go index ec2cdd07..8e875ece 100644 --- a/pkg/api/controller.go +++ b/pkg/api/controller.go @@ -411,7 +411,7 @@ func (c *Controller) InitMetaDB() error { extensionsConfig := c.Config.CopyExtensionsConfig() if extensionsConfig.IsSearchEnabled() || authConfig.IsBasicAuthnEnabled() || extensionsConfig.IsImageTrustEnabled() || - c.Config.IsRetentionEnabled() { + c.Config.IsRetentionEnabled() || c.Config.IsQuotaEnabled() { // Get storage config safely storageConfig := c.Config.CopyStorageConfig() diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index 35ba892d..abd997b8 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -12114,7 +12114,7 @@ func TestPeriodicGC(t *testing.T) { // periodic GC is enabled for sub store So(string(data), ShouldContainSubstring, - fmt.Sprintf("\"SubPaths\":{\"/a\":{\"RootDirectory\":\"%s\",\"Dedupe\":false,\"RemoteCache\":false,\"GC\":true,\"Commit\":false,\"GCDelay\":1000000000,\"GCInterval\":86400000000000", subDir)) //nolint:lll // gofumpt conflicts with lll + fmt.Sprintf("\"SubPaths\":{\"/a\":{\"RootDirectory\":\"%s\",\"MaxRepos\":0,\"Dedupe\":false,\"RemoteCache\":false,\"GC\":true,\"Commit\":false,\"GCDelay\":1000000000,\"GCInterval\":86400000000000", subDir)) //nolint:lll // gofumpt conflicts with lll }) Convey("Periodic gc error", t, func() { diff --git a/pkg/api/quota.go b/pkg/api/quota.go new file mode 100644 index 00000000..0272008a --- /dev/null +++ b/pkg/api/quota.go @@ -0,0 +1,118 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + "sync" + + "github.com/gorilla/mux" + + zerr "zotregistry.dev/zot/v2/errors" + "zotregistry.dev/zot/v2/pkg/api/config" + apiErr "zotregistry.dev/zot/v2/pkg/api/errors" + zcommon "zotregistry.dev/zot/v2/pkg/common" + "zotregistry.dev/zot/v2/pkg/log" + mTypes "zotregistry.dev/zot/v2/pkg/meta/types" +) + +func repoQuotaMiddleware(maxRepos int, metaDB mTypes.MetaDB, log log.Logger) mux.MiddlewareFunc { + var quotaMu sync.Mutex + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + next.ServeHTTP(w, r) + + return + } + + vars := mux.Vars(r) + + // "reference" is only set on /v2/{name}/manifests/{reference} routes. + if _, ok := vars["reference"]; !ok { + next.ServeHTTP(w, r) + + return + } + + repoName := vars["name"] + if repoName == "" { + next.ServeHTTP(w, r) + + return + } + + _, err := metaDB.GetRepoMeta(r.Context(), repoName) + if err == nil { + next.ServeHTTP(w, r) + + return + } + + if !errors.Is(err, zerr.ErrRepoMetaNotFound) { + log.Error().Err(err).Str("repo", repoName). + Msg("failed to check repo existence for quota, allowing push") + next.ServeHTTP(w, r) + + return + } + + quotaMu.Lock() + defer quotaMu.Unlock() + + // Re-check after acquiring the lock: another request may have + // created this repo while we were waiting. + _, err = metaDB.GetRepoMeta(r.Context(), repoName) + if err == nil { + next.ServeHTTP(w, r) + + return + } + + count, err := metaDB.CountRepos(r.Context()) + if err != nil { + log.Error().Err(err).Msg("failed to count repos for quota, allowing push") + next.ServeHTTP(w, r) + + return + } + + if count >= maxRepos { + log.Warn(). + Str("repo", repoName). + Int("current", count). + Int("limit", maxRepos). + Msg("repository quota limit reached, rejecting push") + + detail := map[string]string{"limit": strconv.Itoa(maxRepos)} + zcommon.WriteJSON(w, http.StatusTooManyRequests, + apiErr.NewErrorList(apiErr.NewError(apiErr.TOOMANYREQUESTS).AddDetail(detail))) + + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func setupQuotaMiddleware( + conf *config.Config, + router *mux.Router, + metaDB mTypes.MetaDB, + log log.Logger, +) { + if !conf.IsQuotaEnabled() { + return + } + + if metaDB == nil { + log.Warn().Msg("metaDB is not initialized, repository quota enforcement disabled") + + return + } + + log.Info().Int("maxRepos", conf.Storage.MaxRepos).Msg("repository quota enforcement enabled") + router.Use(repoQuotaMiddleware(conf.Storage.MaxRepos, metaDB, log)) +} diff --git a/pkg/api/quota_test.go b/pkg/api/quota_test.go new file mode 100644 index 00000000..952b100d --- /dev/null +++ b/pkg/api/quota_test.go @@ -0,0 +1,139 @@ +package api_test + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "testing" + + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/resty.v1" + + "zotregistry.dev/zot/v2/pkg/api" + "zotregistry.dev/zot/v2/pkg/api/config" + test "zotregistry.dev/zot/v2/pkg/test/common" + . "zotregistry.dev/zot/v2/pkg/test/image-utils" +) + +func startQuotaServer(t *testing.T, maxRepos int) (string, func()) { + t.Helper() + + port := test.GetFreePort() + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = t.TempDir() + conf.Storage.MaxRepos = maxRepos + + ctlr := api.NewController(conf) + ctlrManager := test.NewControllerManager(ctlr) + ctlrManager.StartAndWait(port) + + return test.GetBaseURL(port), func() { ctlrManager.StopServer() } +} + +func TestQuotaEnforcement(t *testing.T) { + Convey("Given a registry with maxRepos set to 2", t, func() { + baseURL, stop := startQuotaServer(t, 2) + defer stop() + + Convey("Push to two different repos succeeds", func() { + err := UploadImage(CreateRandomImage(), baseURL, "repo1", "v1") + So(err, ShouldBeNil) + + err = UploadImage(CreateRandomImage(), baseURL, "repo2", "v1") + So(err, ShouldBeNil) + + Convey("Push to a third new repo is rejected with 429", func() { + img := CreateRandomImage() + manifestBody, err := json.Marshal(img.Manifest) + So(err, ShouldBeNil) + + resp, err := resty.R(). + SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json"). + SetBody(manifestBody). + Put(baseURL + "/v2/repo3/manifests/v1") + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, http.StatusTooManyRequests) + + var body map[string]any + So(json.Unmarshal(resp.Body(), &body), ShouldBeNil) + errors, ok := body["errors"].([]any) + So(ok, ShouldBeTrue) + So(len(errors), ShouldBeGreaterThan, 0) + firstErr, ok := errors[0].(map[string]any) + So(ok, ShouldBeTrue) + So(firstErr["code"], ShouldEqual, "TOOMANYREQUESTS") + + detail, ok := firstErr["detail"].(map[string]any) + So(ok, ShouldBeTrue) + So(detail["limit"], ShouldEqual, "2") + }) + + Convey("Push a new tag to an existing repo is allowed at the limit", func() { + err := UploadImage(CreateRandomImage(), baseURL, "repo1", "v2") + So(err, ShouldBeNil) + }) + + Convey("Re-pushing an existing tag is allowed at the limit", func() { + err := UploadImage(CreateRandomImage(), baseURL, "repo2", "v1") + So(err, ShouldBeNil) + }) + }) + }) +} + +func TestQuotaDisabled(t *testing.T) { + Convey("Given a registry with maxRepos set to 0 (disabled)", t, func() { + baseURL, stop := startQuotaServer(t, 0) + defer stop() + + Convey("Pushing any number of repos succeeds", func() { + for _, repo := range []string{"repo1", "repo2", "repo3"} { + err := UploadImage(CreateRandomImage(), baseURL, repo, "v1") + So(err, ShouldBeNil) + } + }) + }) +} + +func TestQuotaConcurrency(t *testing.T) { + Convey("Given a registry with maxRepos set to 5", t, func() { + baseURL, stop := startQuotaServer(t, 5) + defer stop() + + Convey("Concurrent pushes to different new repos do not exceed the limit", func() { + const goroutines = 10 + + var wg sync.WaitGroup + results := make([]int, goroutines) + + for i := range goroutines { + idx := i + wg.Go(func() { + err := UploadImage(CreateRandomImage(), baseURL, fmt.Sprintf("concurrent-repo-%d", idx), "v1") + if err != nil { + results[idx] = http.StatusTooManyRequests + } else { + results[idx] = http.StatusCreated + } + }) + } + wg.Wait() + + created := 0 + rejected := 0 + + for _, code := range results { + if code == http.StatusCreated { + created++ + } else { + rejected++ + } + } + + So(created, ShouldBeLessThanOrEqualTo, 5) + So(rejected, ShouldBeGreaterThanOrEqualTo, 5) + }) + }) +} diff --git a/pkg/api/routes.go b/pkg/api/routes.go index c48aec85..91b1937e 100644 --- a/pkg/api/routes.go +++ b/pkg/api/routes.go @@ -224,6 +224,7 @@ func (rh *RouteHandler) SetupRoutes() { ext.SetupImageTrustRoutes(rh.c.Config, prefixedRouter, rh.c.MetaDB, rh.c.Log) ext.SetupMgmtRoutes(rh.c.Config, prefixedRouter, rh.c.Log) ext.SetupUserPreferencesRoutes(rh.c.Config, prefixedRouter, rh.c.MetaDB, rh.c.Log) + setupQuotaMiddleware(rh.c.Config, prefixedDistSpecRouter, rh.c.MetaDB, rh.c.Log) // last should always be UI because it will setup a http.FileServer and paths will be resolved by this FileServer. ext.SetupUIRoutes(rh.c.Config, rh.c.Router, rh.c.Log) } diff --git a/pkg/meta/boltdb/boltdb.go b/pkg/meta/boltdb/boltdb.go index 7f21a00a..e45a44d9 100644 --- a/pkg/meta/boltdb/boltdb.go +++ b/pkg/meta/boltdb/boltdb.go @@ -107,6 +107,18 @@ func (bdw *BoltDB) GetAllRepoNames() ([]string, error) { return repoNames, err } +func (bdw *BoltDB) CountRepos(_ context.Context) (int, error) { + count := 0 + + err := bdw.DB.View(func(tx *bbolt.Tx) error { + count = tx.Bucket([]byte(RepoMetaBuck)).Stats().KeyN + + return nil + }) + + return count, err +} + func (bdw *BoltDB) GetRepoLastUpdated(repo string) time.Time { lastUpdated := time.Time{} diff --git a/pkg/meta/boltdb/boltdb_test.go b/pkg/meta/boltdb/boltdb_test.go index e7ab6342..327a00a5 100644 --- a/pkg/meta/boltdb/boltdb_test.go +++ b/pkg/meta/boltdb/boltdb_test.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/base64" "errors" + "fmt" "math" "testing" "time" @@ -1182,3 +1183,55 @@ func setRepoBlobInfo(repo string, blob []byte, db *bbolt.DB) error { return err } + +func TestBoltDBCountRepos(t *testing.T) { + Convey("CountRepos", t, func() { + tmpDir := t.TempDir() + boltDBParams := boltdb.DBParameters{RootDir: tmpDir} + boltDriver, err := boltdb.GetBoltDriver(boltDBParams) + So(err, ShouldBeNil) + + log := log.NewTestLogger() + + boltdbWrapper, err := boltdb.New(boltDriver, log) + So(boltdbWrapper, ShouldNotBeNil) + So(err, ShouldBeNil) + + ctx := context.Background() + + Convey("returns 0 on empty DB", func() { + count, err := boltdbWrapper.CountRepos(ctx) + So(err, ShouldBeNil) + So(count, ShouldEqual, 0) + }) + + Convey("returns correct count after adding repos", func() { + for i := range 3 { + err := boltdbWrapper.SetRepoMeta(fmt.Sprintf("repo%d", i), mTypes.RepoMeta{ + Name: fmt.Sprintf("repo%d", i), + }) + So(err, ShouldBeNil) + } + + count, err := boltdbWrapper.CountRepos(ctx) + So(err, ShouldBeNil) + So(count, ShouldEqual, 3) + }) + + Convey("returns correct count after deleting a repo", func() { + for i := range 3 { + err := boltdbWrapper.SetRepoMeta(fmt.Sprintf("repo%d", i), mTypes.RepoMeta{ + Name: fmt.Sprintf("repo%d", i), + }) + So(err, ShouldBeNil) + } + + err := boltdbWrapper.DeleteRepoMeta("repo1") + So(err, ShouldBeNil) + + count, err := boltdbWrapper.CountRepos(ctx) + So(err, ShouldBeNil) + So(count, ShouldEqual, 2) + }) + }) +} diff --git a/pkg/meta/dynamodb/dynamodb.go b/pkg/meta/dynamodb/dynamodb.go index 06c25106..3b2f3b81 100644 --- a/pkg/meta/dynamodb/dynamodb.go +++ b/pkg/meta/dynamodb/dynamodb.go @@ -118,6 +118,33 @@ func (dwr *DynamoDB) GetAllRepoNames() ([]string, error) { return repoNames, nil } +func (dwr *DynamoDB) CountRepos(ctx context.Context) (int, error) { + count := int32(0) + + var lastKey map[string]types.AttributeValue + + for { + out, err := dwr.Client.Scan(ctx, &dynamodb.ScanInput{ + TableName: aws.String(dwr.RepoMetaTablename), + Select: types.SelectCount, + ExclusiveStartKey: lastKey, + }) + if err != nil { + return 0, err + } + + count += out.Count + + if out.LastEvaluatedKey == nil { + break + } + + lastKey = out.LastEvaluatedKey + } + + return int(count), nil +} + func (dwr *DynamoDB) GetRepoLastUpdated(repo string) time.Time { resp, err := dwr.Client.GetItem(context.Background(), &dynamodb.GetItemInput{ TableName: aws.String(dwr.RepoBlobsTablename), diff --git a/pkg/meta/dynamodb/dynamodb_test.go b/pkg/meta/dynamodb/dynamodb_test.go index 182891cb..ac4d3467 100644 --- a/pkg/meta/dynamodb/dynamodb_test.go +++ b/pkg/meta/dynamodb/dynamodb_test.go @@ -3,6 +3,7 @@ package dynamodb_test import ( "context" "errors" + "fmt" "os" "testing" "time" @@ -1592,3 +1593,66 @@ func setVersion(client *dynamodb.Client, versionTablename string, version string return err } + +func TestDynamoDBCountRepos(t *testing.T) { + tskip.SkipDynamo(t) + + const region = "us-east-2" + + endpoint := os.Getenv("DYNAMODBMOCK_ENDPOINT") + + uuid, err := guuid.NewV4() + if err != nil { + panic(err) + } + + repoMetaTablename := "RepoMetadataTable" + uuid.String() + versionTablename := "Version" + uuid.String() + imageMetaTablename := "ImageMeta" + uuid.String() + repoBlobsTablename := "RepoBlobs" + uuid.String() + userDataTablename := "UserDataTable" + uuid.String() + apiKeyTablename := "ApiKeyTable" + uuid.String() + + log := log.NewTestLogger() + + Convey("CountRepos", t, func() { + params := mdynamodb.DBDriverParameters{ + Endpoint: endpoint, + Region: region, + RepoMetaTablename: repoMetaTablename, + ImageMetaTablename: imageMetaTablename, + RepoBlobsInfoTablename: repoBlobsTablename, + VersionTablename: versionTablename, + APIKeyTablename: apiKeyTablename, + UserDataTablename: userDataTablename, + } + client, err := mdynamodb.GetDynamoClient(params) + So(err, ShouldBeNil) + + dynamoWrapper, err := mdynamodb.New(client, params, log) + So(err, ShouldBeNil) + + So(dynamoWrapper.ResetTable(dynamoWrapper.RepoMetaTablename), ShouldBeNil) + So(dynamoWrapper.ResetTable(dynamoWrapper.ImageMetaTablename), ShouldBeNil) + + ctx := context.Background() + + Convey("returns 0 on empty table", func() { + count, err := dynamoWrapper.CountRepos(ctx) + So(err, ShouldBeNil) + So(count, ShouldEqual, 0) + }) + + Convey("returns correct count after adding repos", func() { + for i := range 3 { + err := dynamoWrapper.SetRepoReference(ctx, fmt.Sprintf("repo%d", i), "tag", + CreateRandomImage().AsImageMeta()) + So(err, ShouldBeNil) + } + + count, err := dynamoWrapper.CountRepos(ctx) + So(err, ShouldBeNil) + So(count, ShouldEqual, 3) + }) + }) +} diff --git a/pkg/meta/redis/redis.go b/pkg/meta/redis/redis.go index 5bd780f2..289480e8 100644 --- a/pkg/meta/redis/redis.go +++ b/pkg/meta/redis/redis.go @@ -2107,6 +2107,15 @@ func (rc *RedisDB) GetAllRepoNames() ([]string, error) { return foundRepos, nil } +func (rc *RedisDB) CountRepos(ctx context.Context) (int, error) { + count, err := rc.Client.HLen(ctx, rc.RepoMetaKey).Result() + if err != nil { + return 0, fmt.Errorf("failed to count repos: %w", err) + } + + return int(count), nil +} + // ResetDB will delete all data in the DB. // Ideally we would use locks here, but it would require a more complex logic to lock/unlock // everything, and this function is only used in testing, so let's not add that complexity. diff --git a/pkg/meta/redis/redis_test.go b/pkg/meta/redis/redis_test.go index 13decdb0..a7364422 100644 --- a/pkg/meta/redis/redis_test.go +++ b/pkg/meta/redis/redis_test.go @@ -311,6 +311,10 @@ func TestRedisRepoMeta(t *testing.T) { So(err, ShouldBeNil) So(len(repoNames), ShouldEqual, 5) + count, err := metaDB.CountRepos(ctx) + So(err, ShouldBeNil) + So(count, ShouldEqual, 5) + err = metaDB.DeleteRepoMeta("repo2") So(err, ShouldBeNil) @@ -322,6 +326,10 @@ func TestRedisRepoMeta(t *testing.T) { So(err, ShouldBeNil) So(len(repoNames), ShouldEqual, 4) + count, err = metaDB.CountRepos(ctx) + So(err, ShouldBeNil) + So(count, ShouldEqual, 4) + repoMetas, err = metaDB.GetMultipleRepoMeta(ctx, func(repoMeta mTypes.RepoMeta) bool { return true }) So(err, ShouldBeNil) So(len(repoMetas), ShouldEqual, 4) diff --git a/pkg/meta/types/types.go b/pkg/meta/types/types.go index 4a32e65f..f0704ba4 100644 --- a/pkg/meta/types/types.go +++ b/pkg/meta/types/types.go @@ -151,6 +151,8 @@ type MetaDB interface { //nolint:interfacebloat GetAllRepoNames() ([]string, error) + CountRepos(ctx context.Context) (int, error) + // ResetDB will delete all data in the DB ResetDB() error diff --git a/pkg/test/mocks/repo_db_mock.go b/pkg/test/mocks/repo_db_mock.go index f10ee434..7901eed9 100644 --- a/pkg/test/mocks/repo_db_mock.go +++ b/pkg/test/mocks/repo_db_mock.go @@ -102,6 +102,8 @@ type MetaDBMock struct { GetAllRepoNamesFn func() ([]string, error) + CountReposFn func(ctx context.Context) (int, error) + ResetDBFn func() error CloseFn func() error @@ -123,6 +125,14 @@ func (sdm MetaDBMock) GetAllRepoNames() ([]string, error) { return []string{}, nil } +func (sdm MetaDBMock) CountRepos(ctx context.Context) (int, error) { + if sdm.CountReposFn != nil { + return sdm.CountReposFn(ctx) + } + + return 0, nil +} + func (sdm MetaDBMock) GetRepoLastUpdated(repo string) time.Time { if sdm.GetRepoLastUpdatedFn != nil { return sdm.GetRepoLastUpdatedFn(repo) diff --git a/test/blackbox/ci.sh b/test/blackbox/ci.sh index 40ed76f7..8f0d3da2 100755 --- a/test/blackbox/ci.sh +++ b/test/blackbox/ci.sh @@ -19,7 +19,7 @@ tests=("pushpull" "pushpull_authn" "delete_images" "referrers" "metadata" "anony "annotations" "detect_manifest_collision" "cve" "sync" "sync_docker" "sync_replica_cluster" "scrub" "garbage_collect" "metrics" "metrics_minimal" "multiarch_index" "docker_compat" "redis_local" "redis_session_store" "events_nats" "events_http" "events_nats_lint_failure" "events_http_lint_failure" "events_sink_failure" "events_config_decoding" - "fips140" "fips140_authn" "openid_claim_mapping" "upgrade" "upgrade_minimal" "dynamic_tls") + "fips140" "fips140_authn" "openid_claim_mapping" "upgrade" "upgrade_minimal" "dynamic_tls" "quota") for test in ${tests[*]}; do ${BATS} ${BATS_FLAGS} ${SCRIPTPATH}/${test}.bats > ${test}.log & pids+=($!) diff --git a/test/blackbox/quota.bats b/test/blackbox/quota.bats new file mode 100644 index 00000000..8eef8bbb --- /dev/null +++ b/test/blackbox/quota.bats @@ -0,0 +1,124 @@ +# Note: Intended to be run as "make run-blackbox-tests" or "make run-blackbox-ci" +# Makefile target installs & checks all necessary tooling +# Extra tools that are not covered in Makefile target needs to be added in verify_prerequisites() + +load helpers_zot +load ../port_helper + +# Minimal valid OCI manifest used to probe the quota middleware directly via curl. +# The quota middleware rejects manifest PUTs for new repos before content validation, +# so the config blob referenced here does not need to exist in the registry. +MINIMAL_MANIFEST='{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:44136fa355ba77b9ad7b468a8c5e4f9b85d40e49c15ebd6a4e40ac9eb25c6a80","size":2},"layers":[]}' + +function verify_prerequisites { + if [ ! $(command -v curl) ]; then + echo "you need to install curl as a prerequisite to running the tests" >&3 + return 1 + fi + + if [ ! $(command -v jq) ]; then + echo "you need to install jq as a prerequisite to running the tests" >&3 + return 1 + fi + + return 0 +} + +function setup_file() { + # Verify prerequisites are available + if ! $(verify_prerequisites); then + exit 1 + fi + + # Download test data to folder common for the entire suite, not just this file + skopeo --insecure-policy copy --format=oci docker://ghcr.io/project-zot/golang:1.20 oci:${TEST_DATA_DIR}/golang:1.20 + + # Setup zot server with maxRepos=2 + local zot_root_dir=${BATS_FILE_TMPDIR}/zot + local zot_config_file=${BATS_FILE_TMPDIR}/zot_config.json + mkdir -p ${zot_root_dir} + zot_port=$(get_free_port_for_service "zot") + echo ${zot_port} > ${BATS_FILE_TMPDIR}/zot.port + cat > ${zot_config_file}<