feat(api): add repository quota enforcement middleware (#3923)

Adds a configurable maximum repository count per registry instance.
When maxRepos is set on StorageConfig, manifest pushes that would create
a new repository beyond the limit are rejected with HTTP 429
TOOMANYREQUESTS. Pushes to existing repositories are always allowed.

Implemented as an always-available feature in pkg/api (not a build-tag
extension). MaxRepos is a field on StorageConfig, enabled when > 0.

- repoQuotaMiddleware on the dist-spec router intercepts manifest PUTs.
  New-repo pushes are serialized with a sync.Mutex to prevent concurrent
  requests from exceeding the limit.
- Adds CountRepos(ctx) to the MetaDB interface with efficient
  implementations: BoltDB (Stats().KeyN), Redis (HLen), DynamoDB
  (Scan with Select=COUNT).
- Config.IsQuotaEnabled() added, wired into controller.go metaDB init.
- Four integration tests (enforcement, concurrency, disabled,
  unconfigured) and backend-specific CountRepos tests for BoltDB, Redis,
  and DynamoDB.

Signed-off-by: Bachir Khiati <bachir.khiati@gmail.com>
This commit is contained in:
Bachir Khiati
2026-04-13 23:18:34 +03:00
committed by GitHub
parent 82947e801e
commit ba8575d960
18 changed files with 598 additions and 3 deletions
+10
View File
@@ -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:
+12
View File
@@ -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 {
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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() {
+118
View File
@@ -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))
}
+139
View File
@@ -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)
})
})
}
+1
View File
@@ -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)
}
+12
View File
@@ -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{}
+53
View File
@@ -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)
})
})
}
+27
View File
@@ -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),
+64
View File
@@ -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)
})
})
}
+9
View File
@@ -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.
+8
View File
@@ -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)
+2
View File
@@ -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
+10
View File
@@ -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)
+1 -1
View File
@@ -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+=($!)
+124
View File
@@ -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}<<EOF
{
"distSpecVersion": "1.1.1",
"storage": {
"rootDirectory": "${zot_root_dir}",
"maxRepos": 2
},
"http": {
"address": "0.0.0.0",
"port": "${zot_port}"
},
"log": {
"level": "debug",
"output": "${BATS_FILE_TMPDIR}/zot.log"
}
}
EOF
zot_serve ${ZOT_PATH} ${zot_config_file}
wait_zot_reachable ${zot_port}
}
function teardown() {
# conditionally printing on failure is possible from teardown but not from from teardown_file
cat ${BATS_FILE_TMPDIR}/zot.log
}
function teardown_file() {
zot_stop_all
}
@test "push first image to repo1 succeeds" {
zot_port=`cat ${BATS_FILE_TMPDIR}/zot.port`
run skopeo --insecure-policy copy --dest-tls-verify=false \
oci:${TEST_DATA_DIR}/golang:1.20 \
docker://127.0.0.1:${zot_port}/repo1:v1
[ "$status" -eq 0 ]
run curl http://127.0.0.1:${zot_port}/v2/_catalog
[ "$status" -eq 0 ]
[ $(echo "${lines[-1]}" | jq '.repositories | length') -eq 1 ]
}
@test "push second image to repo2 succeeds" {
zot_port=`cat ${BATS_FILE_TMPDIR}/zot.port`
run skopeo --insecure-policy copy --dest-tls-verify=false \
oci:${TEST_DATA_DIR}/golang:1.20 \
docker://127.0.0.1:${zot_port}/repo2:v1
[ "$status" -eq 0 ]
run curl http://127.0.0.1:${zot_port}/v2/_catalog
[ "$status" -eq 0 ]
[ $(echo "${lines[-1]}" | jq '.repositories | length') -eq 2 ]
}
@test "push manifest to new repo3 returns HTTP 429 when quota is reached" {
zot_port=`cat ${BATS_FILE_TMPDIR}/zot.port`
# Push a minimal OCI manifest; the quota middleware rejects it before content validation
run curl -s -o /dev/null -w "%{http_code}" \
-X PUT \
-H "Content-Type: application/vnd.oci.image.manifest.v1+json" \
-d "${MINIMAL_MANIFEST}" \
"http://127.0.0.1:${zot_port}/v2/repo3/manifests/v1"
[ "$status" -eq 0 ]
[ "${lines[-1]}" -eq 429 ]
}
@test "429 response body contains TOOMANYREQUESTS code and limit detail" {
zot_port=`cat ${BATS_FILE_TMPDIR}/zot.port`
run curl -s \
-X PUT \
-H "Content-Type: application/vnd.oci.image.manifest.v1+json" \
-d "${MINIMAL_MANIFEST}" \
"http://127.0.0.1:${zot_port}/v2/repo3/manifests/v1"
[ "$status" -eq 0 ]
[ $(echo "${lines[-1]}" | jq -r '.errors[0].code') = "TOOMANYREQUESTS" ]
[ $(echo "${lines[-1]}" | jq -r '.errors[0].detail.limit') = "2" ]
}
@test "push new tag to existing repo1 at limit succeeds" {
zot_port=`cat ${BATS_FILE_TMPDIR}/zot.port`
run skopeo --insecure-policy copy --dest-tls-verify=false \
oci:${TEST_DATA_DIR}/golang:1.20 \
docker://127.0.0.1:${zot_port}/repo1:v2
[ "$status" -eq 0 ]
}
+6
View File
@@ -454,5 +454,11 @@
"begin": 11520,
"end": 11529
}
},
"blackbox/quota.bats": {
"zot": {
"begin": 11530,
"end": 11539
}
}
}