mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 12:58:02 +08:00
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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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+=($!)
|
||||
|
||||
@@ -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 ]
|
||||
}
|
||||
@@ -454,5 +454,11 @@
|
||||
"begin": 11520,
|
||||
"end": 11529
|
||||
}
|
||||
},
|
||||
"blackbox/quota.bats": {
|
||||
"zot": {
|
||||
"begin": 11530,
|
||||
"end": 11539
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user