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
+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