mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 21:17:58 +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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user