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