feat(quota): Humble beginnings of a quota engine

This is an implementation of a quota engine, and the API routes to
manage its settings. This does *not* contain any enforcement code: this
is just the bedrock, the engine itself.

The goal of the engine is to be flexible and future proof: to be nimble
enough to build on it further, without having to rewrite large parts of
it.

It might feel a little more complicated than necessary, because the goal
was to be able to support scenarios only very few Forgejo instances
need, scenarios the vast majority of mostly smaller instances simply do
not care about. The goal is to support both big and small, and for that,
we need a solid, flexible foundation.

There are thee big parts to the engine: counting quota use, setting
limits, and evaluating whether the usage is within the limits. Sounds
simple on paper, less so in practice!

Quota counting
==============

Quota is counted based on repo ownership, whenever possible, because
repo owners are in ultimate control over the resources they use: they
can delete repos, attachments, everything, even if they don't *own*
those themselves. They can clean up, and will always have the permission
and access required to do so. Would we count quota based on the owning
user, that could lead to situations where a user is unable to free up
space, because they uploaded a big attachment to a repo that has been
taken private since. It's both more fair, and much safer to count quota
against repo owners.

This means that if user A uploads an attachment to an issue opened
against organization O, that will count towards the quota of
organization O, rather than user A.

One's quota usage stats can be queried using the `/user/quota` API
endpoint. To figure out what's eating into it, the
`/user/repos?order_by=size`, `/user/quota/attachments`,
`/user/quota/artifacts`, and `/user/quota/packages` endpoints should be
consulted. There's also `/user/quota/check?subject=<...>` to check
whether the signed-in user is within a particular quota limit.

Quotas are counted based on sizes stored in the database.

Setting quota limits
====================

There are different "subjects" one can limit usage for. At this time,
only size-based limits are implemented, which are:

- `size:all`: As the name would imply, the total size of everything
  Forgejo tracks.
- `size:repos:all`: The total size of all repositories (not including
  LFS).
- `size:repos:public`: The total size of all public repositories (not
  including LFS).
- `size:repos:private`: The total size of all private repositories (not
  including LFS).
- `size:git:all`: The total size of all git data (including all
  repositories, and LFS).
- `size:git:lfs`: The size of all git LFS data (either in private or
  public repos).
- `size:assets:all`: The size of all assets tracked by Forgejo.
- `size:assets:attachments:all`: The size of all kinds of attachments
  tracked by Forgejo.
- `size:assets:attachments:issues`: Size of all attachments attached to
  issues, including issue comments.
- `size:assets:attachments:releases`: Size of all attachments attached
  to releases. This does *not* include automatically generated archives.
- `size:assets:artifacts`: Size of all Action artifacts.
- `size:assets:packages:all`: Size of all Packages.
- `size:wiki`: Wiki size

Wiki size is currently not tracked, and the engine will always deem it
within quota.

These subjects are built into Rules, which set a limit on *all* subjects
within a rule. Thus, we can create a rule that says: "1Gb limit on all
release assets, all packages, and git LFS, combined". For a rule to
stand, the total sum of all subjects must be below the rule's limit.

Rules are in turn collected into groups. A group is just a name, and a
list of rules. For a group to stand, all of its rules must stand. Thus,
if we have a group with two rules, one that sets a combined 1Gb limit on
release assets, all packages, and git LFS, and another rule that sets a
256Mb limit on packages, if the user has 512Mb of packages, the group
will not stand, because the second rule deems it over quota. Similarly,
if the user has only 128Mb of packages, but 900Mb of release assets, the
group will not stand, because the combined size of packages and release
assets is over the 1Gb limit of the first rule.

Groups themselves are collected into Group Lists. A group list stands
when *any* of the groups within stand. This allows an administrator to
set conservative defaults, but then place select users into additional
groups that increase some aspect of their limits.

To top it off, it is possible to set the default quota groups a user
belongs to in `app.ini`. If there's no explicit assignment, the engine
will use the default groups. This makes it possible to avoid having to
assign each and every user a list of quota groups, and only those need
to be explicitly assigned who need a different set of groups than the
defaults.

If a user has any quota groups assigned to them, the default list will
not be considered for them.

The management APIs
===================

This commit contains the engine itself, its unit tests, and the quota
management APIs. It does not contain any enforcement.

The APIs are documented in-code, and in the swagger docs, and the
integration tests can serve as an example on how to use them.

Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
This commit is contained in:
Gergely Nagy 2024-07-06 10:25:41 +02:00
parent 250f87db59
commit e1fe3bbdc0
No known key found for this signature in database
28 changed files with 5435 additions and 6 deletions

View file

@ -76,6 +76,8 @@ var migrations = []*Migration{
NewMigration("Create the `following_repo` table", CreateFollowingRepoTable),
// v19 -> v20
NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable),
// v20 -> v21
NewMigration("Creating Quota-related tables", CreateQuotaTables),
}
// GetCurrentDBVersion returns the current Forgejo database version.

View file

@ -0,0 +1,52 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forgejo_migrations //nolint:revive
import "xorm.io/xorm"
type (
QuotaLimitSubject int
QuotaLimitSubjects []QuotaLimitSubject
QuotaKind int
)
type QuotaRule struct {
Name string `xorm:"pk not null"`
Limit int64 `xorm:"NOT NULL"`
Subjects QuotaLimitSubjects
}
type QuotaGroup struct {
Name string `xorm:"pk NOT NULL"`
}
type QuotaGroupRuleMapping struct {
ID int64 `xorm:"pk autoincr"`
GroupName string `xorm:"index unique(qgrm_gr) not null"`
RuleName string `xorm:"unique(qgrm_gr) not null"`
}
type QuotaGroupMapping struct {
ID int64 `xorm:"pk autoincr"`
Kind QuotaKind `xorm:"unique(qgm_kmg) not null"`
MappedID int64 `xorm:"unique(qgm_kmg) not null"`
GroupName string `xorm:"index unique(qgm_kmg) not null"`
}
func CreateQuotaTables(x *xorm.Engine) error {
if err := x.Sync(new(QuotaRule)); err != nil {
return err
}
if err := x.Sync(new(QuotaGroup)); err != nil {
return err
}
if err := x.Sync(new(QuotaGroupRuleMapping)); err != nil {
return err
}
return x.Sync(new(QuotaGroupMapping))
}

127
models/quota/errors.go Normal file
View file

@ -0,0 +1,127 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import "fmt"
type ErrRuleAlreadyExists struct {
Name string
}
func IsErrRuleAlreadyExists(err error) bool {
_, ok := err.(ErrRuleAlreadyExists)
return ok
}
func (err ErrRuleAlreadyExists) Error() string {
return fmt.Sprintf("rule already exists: [name: %s]", err.Name)
}
type ErrRuleNotFound struct {
Name string
}
func IsErrRuleNotFound(err error) bool {
_, ok := err.(ErrRuleNotFound)
return ok
}
func (err ErrRuleNotFound) Error() string {
return fmt.Sprintf("rule not found: [name: %s]", err.Name)
}
type ErrGroupAlreadyExists struct {
Name string
}
func IsErrGroupAlreadyExists(err error) bool {
_, ok := err.(ErrGroupAlreadyExists)
return ok
}
func (err ErrGroupAlreadyExists) Error() string {
return fmt.Sprintf("group already exists: [name: %s]", err.Name)
}
type ErrGroupNotFound struct {
Name string
}
func IsErrGroupNotFound(err error) bool {
_, ok := err.(ErrGroupNotFound)
return ok
}
func (err ErrGroupNotFound) Error() string {
return fmt.Sprintf("group not found: [group: %s]", err.Name)
}
type ErrUserAlreadyInGroup struct {
GroupName string
UserID int64
}
func IsErrUserAlreadyInGroup(err error) bool {
_, ok := err.(ErrUserAlreadyInGroup)
return ok
}
func (err ErrUserAlreadyInGroup) Error() string {
return fmt.Sprintf("user already in group: [group: %s, userID: %d]", err.GroupName, err.UserID)
}
type ErrUserNotInGroup struct {
GroupName string
UserID int64
}
func IsErrUserNotInGroup(err error) bool {
_, ok := err.(ErrUserNotInGroup)
return ok
}
func (err ErrUserNotInGroup) Error() string {
return fmt.Sprintf("user not in group: [group: %s, userID: %d]", err.GroupName, err.UserID)
}
type ErrRuleAlreadyInGroup struct {
GroupName string
RuleName string
}
func IsErrRuleAlreadyInGroup(err error) bool {
_, ok := err.(ErrRuleAlreadyInGroup)
return ok
}
func (err ErrRuleAlreadyInGroup) Error() string {
return fmt.Sprintf("rule already in group: [group: %s, rule: %s]", err.GroupName, err.RuleName)
}
type ErrRuleNotInGroup struct {
GroupName string
RuleName string
}
func IsErrRuleNotInGroup(err error) bool {
_, ok := err.(ErrRuleNotInGroup)
return ok
}
func (err ErrRuleNotInGroup) Error() string {
return fmt.Sprintf("rule not in group: [group: %s, rule: %s]", err.GroupName, err.RuleName)
}
type ErrParseLimitSubjectUnrecognized struct {
Subject string
}
func IsErrParseLimitSubjectUnrecognized(err error) bool {
_, ok := err.(ErrParseLimitSubjectUnrecognized)
return ok
}
func (err ErrParseLimitSubjectUnrecognized) Error() string {
return fmt.Sprintf("unrecognized quota limit subject: [subject: %s]", err.Subject)
}

401
models/quota/group.go Normal file
View file

@ -0,0 +1,401 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import (
"context"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"xorm.io/builder"
)
type (
GroupList []*Group
Group struct {
// Name of the quota group
Name string `json:"name" xorm:"pk NOT NULL" binding:"Required"`
Rules []Rule `json:"rules" xorm:"-"`
}
)
type GroupRuleMapping struct {
ID int64 `xorm:"pk autoincr" json:"-"`
GroupName string `xorm:"index unique(qgrm_gr) not null" json:"group_name"`
RuleName string `xorm:"unique(qgrm_gr) not null" json:"rule_name"`
}
type Kind int
const (
KindUser Kind = iota
)
type GroupMapping struct {
ID int64 `xorm:"pk autoincr"`
Kind Kind `xorm:"unique(qgm_kmg) not null"`
MappedID int64 `xorm:"unique(qgm_kmg) not null"`
GroupName string `xorm:"index unique(qgm_kmg) not null"`
}
func (g *Group) TableName() string {
return "quota_group"
}
func (grm *GroupRuleMapping) TableName() string {
return "quota_group_rule_mapping"
}
func (ugm *GroupMapping) TableName() string {
return "quota_group_mapping"
}
func (g *Group) LoadRules(ctx context.Context) error {
return db.GetEngine(ctx).Select("`quota_rule`.*").
Table("quota_rule").
Join("INNER", "`quota_group_rule_mapping`", "`quota_group_rule_mapping`.rule_name = `quota_rule`.name").
Where("`quota_group_rule_mapping`.group_name = ?", g.Name).
Find(&g.Rules)
}
func (g *Group) isUserInGroup(ctx context.Context, userID int64) (bool, error) {
return db.GetEngine(ctx).
Where("kind = ? AND mapped_id = ? AND group_name = ?", KindUser, userID, g.Name).
Get(&GroupMapping{})
}
func (g *Group) AddUserByID(ctx context.Context, userID int64) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
exists, err := g.isUserInGroup(ctx, userID)
if err != nil {
return err
} else if exists {
return ErrUserAlreadyInGroup{GroupName: g.Name, UserID: userID}
}
_, err = db.GetEngine(ctx).Insert(&GroupMapping{
Kind: KindUser,
MappedID: userID,
GroupName: g.Name,
})
if err != nil {
return err
}
return committer.Commit()
}
func (g *Group) RemoveUserByID(ctx context.Context, userID int64) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
exists, err := g.isUserInGroup(ctx, userID)
if err != nil {
return err
} else if !exists {
return ErrUserNotInGroup{GroupName: g.Name, UserID: userID}
}
_, err = db.GetEngine(ctx).Delete(&GroupMapping{
Kind: KindUser,
MappedID: userID,
GroupName: g.Name,
})
if err != nil {
return err
}
return committer.Commit()
}
func (g *Group) isRuleInGroup(ctx context.Context, ruleName string) (bool, error) {
return db.GetEngine(ctx).
Where("group_name = ? AND rule_name = ?", g.Name, ruleName).
Get(&GroupRuleMapping{})
}
func (g *Group) AddRuleByName(ctx context.Context, ruleName string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
exists, err := DoesRuleExist(ctx, ruleName)
if err != nil {
return err
} else if !exists {
return ErrRuleNotFound{Name: ruleName}
}
has, err := g.isRuleInGroup(ctx, ruleName)
if err != nil {
return err
} else if has {
return ErrRuleAlreadyInGroup{GroupName: g.Name, RuleName: ruleName}
}
_, err = db.GetEngine(ctx).Insert(&GroupRuleMapping{
GroupName: g.Name,
RuleName: ruleName,
})
if err != nil {
return err
}
return committer.Commit()
}
func (g *Group) RemoveRuleByName(ctx context.Context, ruleName string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
exists, err := g.isRuleInGroup(ctx, ruleName)
if err != nil {
return err
} else if !exists {
return ErrRuleNotInGroup{GroupName: g.Name, RuleName: ruleName}
}
_, err = db.GetEngine(ctx).Delete(&GroupRuleMapping{
GroupName: g.Name,
RuleName: ruleName,
})
if err != nil {
return err
}
return committer.Commit()
}
var affectsMap = map[LimitSubject]LimitSubjects{
LimitSubjectSizeAll: {
LimitSubjectSizeReposAll,
LimitSubjectSizeGitLFS,
LimitSubjectSizeAssetsAll,
},
LimitSubjectSizeReposAll: {
LimitSubjectSizeReposPublic,
LimitSubjectSizeReposPrivate,
},
LimitSubjectSizeAssetsAll: {
LimitSubjectSizeAssetsAttachmentsAll,
LimitSubjectSizeAssetsArtifacts,
LimitSubjectSizeAssetsPackagesAll,
},
LimitSubjectSizeAssetsAttachmentsAll: {
LimitSubjectSizeAssetsAttachmentsIssues,
LimitSubjectSizeAssetsAttachmentsReleases,
},
}
func (g *Group) Evaluate(used Used, forSubject LimitSubject) (bool, bool) {
var found bool
for _, rule := range g.Rules {
ok, has := rule.Evaluate(used, forSubject)
if has {
found = true
if !ok {
return false, true
}
}
}
if !found {
// If Evaluation for forSubject did not succeed, try evaluating against
// subjects below
for _, subject := range affectsMap[forSubject] {
ok, has := g.Evaluate(used, subject)
if has {
found = true
if !ok {
return false, true
}
}
}
}
return true, found
}
func (gl *GroupList) Evaluate(used Used, forSubject LimitSubject) bool {
// If there are no groups, default to success:
if gl == nil || len(*gl) == 0 {
return true
}
for _, group := range *gl {
ok, has := group.Evaluate(used, forSubject)
if has && ok {
return true
}
}
return false
}
func GetGroupByName(ctx context.Context, name string) (*Group, error) {
var group Group
has, err := db.GetEngine(ctx).Where("name = ?", name).Get(&group)
if has {
if err = group.LoadRules(ctx); err != nil {
return nil, err
}
return &group, nil
}
return nil, err
}
func ListGroups(ctx context.Context) (GroupList, error) {
var groups GroupList
err := db.GetEngine(ctx).Find(&groups)
return groups, err
}
func doesGroupExist(ctx context.Context, name string) (bool, error) {
return db.GetEngine(ctx).Where("name = ?", name).Get(&Group{})
}
func CreateGroup(ctx context.Context, name string) (*Group, error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return nil, err
}
defer committer.Close()
exists, err := doesGroupExist(ctx, name)
if err != nil {
return nil, err
} else if exists {
return nil, ErrGroupAlreadyExists{Name: name}
}
group := Group{Name: name}
_, err = db.GetEngine(ctx).Insert(group)
if err != nil {
return nil, err
}
return &group, committer.Commit()
}
func ListUsersInGroup(ctx context.Context, name string) ([]*user_model.User, error) {
group, err := GetGroupByName(ctx, name)
if err != nil {
return nil, err
}
var users []*user_model.User
err = db.GetEngine(ctx).Select("`user`.*").
Table("user").
Join("INNER", "`quota_group_mapping`", "`quota_group_mapping`.mapped_id = `user`.id").
Where("`quota_group_mapping`.kind = ? AND `quota_group_mapping`.group_name = ?", KindUser, group.Name).
Find(&users)
return users, err
}
func DeleteGroupByName(ctx context.Context, name string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
_, err = db.GetEngine(ctx).Delete(GroupMapping{
GroupName: name,
})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).Delete(GroupRuleMapping{
GroupName: name,
})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).Delete(Group{Name: name})
if err != nil {
return err
}
return committer.Commit()
}
func SetUserGroups(ctx context.Context, userID int64, groups *[]string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
// First: remove the user from any groups
_, err = db.GetEngine(ctx).Where("kind = ? AND mapped_id = ?", KindUser, userID).Delete(GroupMapping{})
if err != nil {
return err
}
if groups == nil {
return nil
}
// Then add the user to each group listed
for _, groupName := range *groups {
group, err := GetGroupByName(ctx, groupName)
if err != nil {
return err
}
if group == nil {
return ErrGroupNotFound{Name: groupName}
}
err = group.AddUserByID(ctx, userID)
if err != nil {
return err
}
}
return committer.Commit()
}
func GetGroupsForUser(ctx context.Context, userID int64) (GroupList, error) {
var groups GroupList
err := db.GetEngine(ctx).
Where(builder.In("name",
builder.Select("group_name").
From("quota_group_mapping").
Where(builder.And(
builder.Eq{"kind": KindUser},
builder.Eq{"mapped_id": userID}),
))).
Find(&groups)
if err != nil {
return nil, err
}
if len(groups) == 0 {
err = db.GetEngine(ctx).Where(builder.In("name", setting.Quota.DefaultGroups)).Find(&groups)
if err != nil {
return nil, err
}
if len(groups) == 0 {
return nil, nil
}
}
for _, group := range groups {
err = group.LoadRules(ctx)
if err != nil {
return nil, err
}
}
return groups, nil
}

View file

@ -0,0 +1,69 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import "fmt"
type (
LimitSubject int
LimitSubjects []LimitSubject
)
const (
LimitSubjectNone LimitSubject = iota
LimitSubjectSizeAll
LimitSubjectSizeReposAll
LimitSubjectSizeReposPublic
LimitSubjectSizeReposPrivate
LimitSubjectSizeGitAll
LimitSubjectSizeGitLFS
LimitSubjectSizeAssetsAll
LimitSubjectSizeAssetsAttachmentsAll
LimitSubjectSizeAssetsAttachmentsIssues
LimitSubjectSizeAssetsAttachmentsReleases
LimitSubjectSizeAssetsArtifacts
LimitSubjectSizeAssetsPackagesAll
LimitSubjectSizeWiki
LimitSubjectFirst = LimitSubjectSizeAll
LimitSubjectLast = LimitSubjectSizeWiki
)
var limitSubjectRepr = map[string]LimitSubject{
"none": LimitSubjectNone,
"size:all": LimitSubjectSizeAll,
"size:repos:all": LimitSubjectSizeReposAll,
"size:repos:public": LimitSubjectSizeReposPublic,
"size:repos:private": LimitSubjectSizeReposPrivate,
"size:git:all": LimitSubjectSizeGitAll,
"size:git:lfs": LimitSubjectSizeGitLFS,
"size:assets:all": LimitSubjectSizeAssetsAll,
"size:assets:attachments:all": LimitSubjectSizeAssetsAttachmentsAll,
"size:assets:attachments:issues": LimitSubjectSizeAssetsAttachmentsIssues,
"size:assets:attachments:releases": LimitSubjectSizeAssetsAttachmentsReleases,
"size:assets:artifacts": LimitSubjectSizeAssetsArtifacts,
"size:assets:packages:all": LimitSubjectSizeAssetsPackagesAll,
"size:assets:wiki": LimitSubjectSizeWiki,
}
func (subject LimitSubject) String() string {
for repr, limit := range limitSubjectRepr {
if limit == subject {
return repr
}
}
return "<unknown>"
}
func (subjects LimitSubjects) GoString() string {
return fmt.Sprintf("%T{%+v}", subjects, subjects)
}
func ParseLimitSubject(repr string) (LimitSubject, error) {
result, has := limitSubjectRepr[repr]
if !has {
return LimitSubjectNone, ErrParseLimitSubjectUnrecognized{Subject: repr}
}
return result, nil
}

36
models/quota/quota.go Normal file
View file

@ -0,0 +1,36 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/setting"
)
func init() {
db.RegisterModel(new(Rule))
db.RegisterModel(new(Group))
db.RegisterModel(new(GroupRuleMapping))
db.RegisterModel(new(GroupMapping))
}
func EvaluateForUser(ctx context.Context, userID int64, subject LimitSubject) (bool, error) {
if !setting.Quota.Enabled {
return true, nil
}
groups, err := GetGroupsForUser(ctx, userID)
if err != nil {
return false, err
}
used, err := GetUsedForUser(ctx, userID)
if err != nil {
return false, err
}
return groups.Evaluate(*used, subject), nil
}

View file

@ -0,0 +1,208 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota_test
import (
"testing"
quota_model "code.gitea.io/gitea/models/quota"
"github.com/stretchr/testify/assert"
)
func TestQuotaGroupAllRulesMustPass(t *testing.T) {
unlimitedRule := quota_model.Rule{
Limit: -1,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
group := quota_model.Group{
Rules: []quota_model.Rule{
unlimitedRule,
denyRule,
},
}
used := quota_model.Used{}
used.Size.Repos.Public = 1024
// Within a group, *all* rules must pass. Thus, if we have a deny-all rule,
// and an unlimited rule, that will always fail.
ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, has)
assert.False(t, ok)
}
func TestQuotaGroupRuleScenario1(t *testing.T) {
group := quota_model.Group{
Rules: []quota_model.Rule{
{
Limit: 1024,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAssetsAttachmentsReleases,
quota_model.LimitSubjectSizeGitLFS,
quota_model.LimitSubjectSizeAssetsPackagesAll,
},
},
{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeGitLFS,
},
},
},
}
used := quota_model.Used{}
used.Size.Assets.Attachments.Releases = 512
used.Size.Assets.Packages.All = 256
used.Size.Git.LFS = 16
ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeAssetsAttachmentsReleases)
assert.True(t, has, "size:assets:attachments:releases is covered")
assert.True(t, ok, "size:assets:attachments:releases passes")
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll)
assert.True(t, has, "size:assets:packages:all is covered")
assert.True(t, ok, "size:assets:packages:all passes")
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS)
assert.True(t, has, "size:git:lfs is covered")
assert.False(t, ok, "size:git:lfs fails")
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, has, "size:all is covered")
assert.False(t, ok, "size:all fails")
}
func TestQuotaGroupRuleCombination(t *testing.T) {
repoRule := quota_model.Rule{
Limit: 4096,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeReposAll,
},
}
packagesRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAssetsPackagesAll,
},
}
used := quota_model.Used{}
used.Size.Repos.Public = 1024
used.Size.Assets.Packages.All = 1024
group := quota_model.Group{
Rules: []quota_model.Rule{
repoRule,
packagesRule,
},
}
// Git LFS isn't covered by any rule
_, has := group.Evaluate(used, quota_model.LimitSubjectSizeGitLFS)
assert.False(t, has)
// repos:all is covered, and is passing
ok, has := group.Evaluate(used, quota_model.LimitSubjectSizeReposAll)
assert.True(t, has)
assert.True(t, ok)
// packages:all is covered, and is failing
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAssetsPackagesAll)
assert.True(t, has)
assert.False(t, ok)
// size:all is covered, and is failing (due to packages:all being over quota)
ok, has = group.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, has, "size:all should be covered")
assert.False(t, ok, "size:all should fail")
}
func TestQuotaGroupListsRequireOnlyOnePassing(t *testing.T) {
unlimitedRule := quota_model.Rule{
Limit: -1,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyGroup := quota_model.Group{
Rules: []quota_model.Rule{
denyRule,
},
}
unlimitedGroup := quota_model.Group{
Rules: []quota_model.Rule{
unlimitedRule,
},
}
groups := quota_model.GroupList{&denyGroup, &unlimitedGroup}
used := quota_model.Used{}
used.Size.Repos.Public = 1024
// In a group list, if any group passes, the entire evaluation passes.
ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, ok)
}
func TestQuotaGroupListAllFailing(t *testing.T) {
denyRule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
limitedRule := quota_model.Rule{
Limit: 1024,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
denyGroup := quota_model.Group{
Rules: []quota_model.Rule{
denyRule,
},
}
limitedGroup := quota_model.Group{
Rules: []quota_model.Rule{
limitedRule,
},
}
groups := quota_model.GroupList{&denyGroup, &limitedGroup}
used := quota_model.Used{}
used.Size.Repos.Public = 2048
ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.False(t, ok)
}
func TestQuotaGroupListEmpty(t *testing.T) {
groups := quota_model.GroupList{}
used := quota_model.Used{}
used.Size.Repos.Public = 2048
ok := groups.Evaluate(used, quota_model.LimitSubjectSizeAll)
assert.True(t, ok)
}

View file

@ -0,0 +1,304 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota_test
import (
"testing"
quota_model "code.gitea.io/gitea/models/quota"
"github.com/stretchr/testify/assert"
)
func makeFullyUsed() quota_model.Used {
return quota_model.Used{
Size: quota_model.UsedSize{
Repos: quota_model.UsedSizeRepos{
Public: 1024,
Private: 1024,
},
Git: quota_model.UsedSizeGit{
LFS: 1024,
},
Assets: quota_model.UsedSizeAssets{
Attachments: quota_model.UsedSizeAssetsAttachments{
Issues: 1024,
Releases: 1024,
},
Artifacts: 1024,
Packages: quota_model.UsedSizeAssetsPackages{
All: 1024,
},
},
},
}
}
func makePartiallyUsed() quota_model.Used {
return quota_model.Used{
Size: quota_model.UsedSize{
Repos: quota_model.UsedSizeRepos{
Public: 1024,
},
Assets: quota_model.UsedSizeAssets{
Attachments: quota_model.UsedSizeAssetsAttachments{
Releases: 1024,
},
},
},
}
}
func setUsed(used quota_model.Used, subject quota_model.LimitSubject, value int64) *quota_model.Used {
switch subject {
case quota_model.LimitSubjectSizeReposPublic:
used.Size.Repos.Public = value
return &used
case quota_model.LimitSubjectSizeReposPrivate:
used.Size.Repos.Private = value
return &used
case quota_model.LimitSubjectSizeGitLFS:
used.Size.Git.LFS = value
return &used
case quota_model.LimitSubjectSizeAssetsAttachmentsIssues:
used.Size.Assets.Attachments.Issues = value
return &used
case quota_model.LimitSubjectSizeAssetsAttachmentsReleases:
used.Size.Assets.Attachments.Releases = value
return &used
case quota_model.LimitSubjectSizeAssetsArtifacts:
used.Size.Assets.Artifacts = value
return &used
case quota_model.LimitSubjectSizeAssetsPackagesAll:
used.Size.Assets.Packages.All = value
return &used
case quota_model.LimitSubjectSizeWiki:
}
return nil
}
func assertEvaluation(t *testing.T, rule quota_model.Rule, used quota_model.Used, subject quota_model.LimitSubject, expected bool) {
t.Helper()
t.Run(subject.String(), func(t *testing.T) {
ok, has := rule.Evaluate(used, subject)
assert.True(t, has)
assert.Equal(t, expected, ok)
})
}
func TestQuotaRuleNoEvaluation(t *testing.T) {
rule := quota_model.Rule{
Limit: 1024,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAssetsAttachmentsAll,
},
}
used := quota_model.Used{}
used.Size.Repos.Public = 4096
_, has := rule.Evaluate(used, quota_model.LimitSubjectSizeReposAll)
// We have a rule for "size:assets:attachments:all", and query for
// "size:repos:all". We don't cover that subject, so the evaluation returns
// with no rules found.
assert.False(t, has)
}
func TestQuotaRuleDirectEvaluation(t *testing.T) {
// This function is meant to test direct rule evaluation: cases where we set
// a rule for a subject, and we evaluate against the same subject.
runTest := func(t *testing.T, subject quota_model.LimitSubject, limit, used int64, expected bool) {
t.Helper()
rule := quota_model.Rule{
Limit: limit,
Subjects: quota_model.LimitSubjects{
subject,
},
}
usedObj := setUsed(quota_model.Used{}, subject, used)
if usedObj == nil {
return
}
assertEvaluation(t, rule, *usedObj, subject, expected)
}
t.Run("limit:0", func(t *testing.T) {
// With limit:0, nothing used is fine.
t.Run("used:0", func(t *testing.T) {
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
runTest(t, subject, 0, 0, true)
}
})
// With limit:0, any usage will fail evaluation
t.Run("used:512", func(t *testing.T) {
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
runTest(t, subject, 0, 512, false)
}
})
})
t.Run("limit:unlimited", func(t *testing.T) {
// With no limits, any usage will succeed evaluation
t.Run("used:512", func(t *testing.T) {
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
runTest(t, subject, -1, 512, true)
}
})
})
t.Run("limit:1024", func(t *testing.T) {
// With a set limit, usage below the limit succeeds
t.Run("used:512", func(t *testing.T) {
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
runTest(t, subject, 1024, 512, true)
}
})
// With a set limit, usage above the limit fails
t.Run("used:2048", func(t *testing.T) {
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
runTest(t, subject, 1024, 2048, false)
}
})
})
}
func TestQuotaRuleCombined(t *testing.T) {
rule := quota_model.Rule{
Limit: 1024,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeGitLFS,
quota_model.LimitSubjectSizeAssetsAttachmentsReleases,
quota_model.LimitSubjectSizeAssetsPackagesAll,
},
}
used := quota_model.Used{
Size: quota_model.UsedSize{
Repos: quota_model.UsedSizeRepos{
Public: 4096,
},
Git: quota_model.UsedSizeGit{
LFS: 256,
},
Assets: quota_model.UsedSizeAssets{
Attachments: quota_model.UsedSizeAssetsAttachments{
Issues: 2048,
Releases: 256,
},
Packages: quota_model.UsedSizeAssetsPackages{
All: 2560,
},
},
},
}
expectationMap := map[quota_model.LimitSubject]bool{
quota_model.LimitSubjectSizeGitLFS: false,
quota_model.LimitSubjectSizeAssetsAttachmentsReleases: false,
quota_model.LimitSubjectSizeAssetsPackagesAll: false,
}
for subject := quota_model.LimitSubjectFirst; subject <= quota_model.LimitSubjectLast; subject++ {
t.Run(subject.String(), func(t *testing.T) {
evalOk, evalHas := rule.Evaluate(used, subject)
expected, expectedHas := expectationMap[subject]
assert.Equal(t, expectedHas, evalHas)
if expectedHas {
assert.Equal(t, expected, evalOk)
}
})
}
}
func TestQuotaRuleSizeAll(t *testing.T) {
runTests := func(t *testing.T, rule quota_model.Rule, expected bool) {
t.Helper()
subject := quota_model.LimitSubjectSizeAll
t.Run("used:0", func(t *testing.T) {
used := quota_model.Used{}
assertEvaluation(t, rule, used, subject, true)
})
t.Run("used:some-each", func(t *testing.T) {
used := makeFullyUsed()
assertEvaluation(t, rule, used, subject, expected)
})
t.Run("used:some", func(t *testing.T) {
used := makePartiallyUsed()
assertEvaluation(t, rule, used, subject, expected)
})
}
// With all limits set to 0, evaluation always fails if usage > 0
t.Run("rule:0", func(t *testing.T) {
rule := quota_model.Rule{
Limit: 0,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
runTests(t, rule, false)
})
// With no limits, evaluation always succeeds
t.Run("rule:unlimited", func(t *testing.T) {
rule := quota_model.Rule{
Limit: -1,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
runTests(t, rule, true)
})
// With a specific, very generous limit, evaluation succeeds if the limit isn't exhausted
t.Run("rule:generous", func(t *testing.T) {
rule := quota_model.Rule{
Limit: 102400,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
runTests(t, rule, true)
t.Run("limit exhaustion", func(t *testing.T) {
used := quota_model.Used{
Size: quota_model.UsedSize{
Repos: quota_model.UsedSizeRepos{
Public: 204800,
},
},
}
assertEvaluation(t, rule, used, quota_model.LimitSubjectSizeAll, false)
})
})
// With a specific, small limit, evaluation fails
t.Run("rule:limited", func(t *testing.T) {
rule := quota_model.Rule{
Limit: 512,
Subjects: quota_model.LimitSubjects{
quota_model.LimitSubjectSizeAll,
},
}
runTests(t, rule, false)
})
}

127
models/quota/rule.go Normal file
View file

@ -0,0 +1,127 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import (
"context"
"slices"
"code.gitea.io/gitea/models/db"
)
type Rule struct {
Name string `xorm:"pk not null" json:"name,omitempty"`
Limit int64 `xorm:"NOT NULL" binding:"Required" json:"limit"`
Subjects LimitSubjects `json:"subjects,omitempty"`
}
func (r *Rule) TableName() string {
return "quota_rule"
}
func (r Rule) Evaluate(used Used, forSubject LimitSubject) (bool, bool) {
// If there's no limit, short circuit out
if r.Limit == -1 {
return true, true
}
// If the rule does not cover forSubject, bail out early
if !slices.Contains(r.Subjects, forSubject) {
return false, false
}
var sum int64
for _, subject := range r.Subjects {
sum += used.CalculateFor(subject)
}
return sum <= r.Limit, true
}
func (r *Rule) Edit(ctx context.Context, limit *int64, subjects *LimitSubjects) (*Rule, error) {
cols := []string{}
if limit != nil {
r.Limit = *limit
cols = append(cols, "limit")
}
if subjects != nil {
r.Subjects = *subjects
cols = append(cols, "subjects")
}
_, err := db.GetEngine(ctx).Where("name = ?", r.Name).Cols(cols...).Update(r)
return r, err
}
func GetRuleByName(ctx context.Context, name string) (*Rule, error) {
var rule Rule
has, err := db.GetEngine(ctx).Where("name = ?", name).Get(&rule)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return &rule, err
}
func ListRules(ctx context.Context) ([]Rule, error) {
var rules []Rule
err := db.GetEngine(ctx).Find(&rules)
return rules, err
}
func DoesRuleExist(ctx context.Context, name string) (bool, error) {
return db.GetEngine(ctx).
Where("name = ?", name).
Get(&Rule{})
}
func CreateRule(ctx context.Context, name string, limit int64, subjects LimitSubjects) (*Rule, error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return nil, err
}
defer committer.Close()
exists, err := DoesRuleExist(ctx, name)
if err != nil {
return nil, err
} else if exists {
return nil, ErrRuleAlreadyExists{Name: name}
}
rule := Rule{
Name: name,
Limit: limit,
Subjects: subjects,
}
_, err = db.GetEngine(ctx).Insert(rule)
if err != nil {
return nil, err
}
return &rule, committer.Commit()
}
func DeleteRuleByName(ctx context.Context, name string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
_, err = db.GetEngine(ctx).Delete(GroupRuleMapping{
RuleName: name,
})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).Delete(Rule{Name: name})
if err != nil {
return err
}
return committer.Commit()
}

252
models/quota/used.go Normal file
View file

@ -0,0 +1,252 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package quota
import (
"context"
action_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
package_model "code.gitea.io/gitea/models/packages"
repo_model "code.gitea.io/gitea/models/repo"
"xorm.io/builder"
)
type Used struct {
Size UsedSize
}
type UsedSize struct {
Repos UsedSizeRepos
Git UsedSizeGit
Assets UsedSizeAssets
}
func (u UsedSize) All() int64 {
return u.Repos.All() + u.Git.All(u.Repos) + u.Assets.All()
}
type UsedSizeRepos struct {
Public int64
Private int64
}
func (u UsedSizeRepos) All() int64 {
return u.Public + u.Private
}
type UsedSizeGit struct {
LFS int64
}
func (u UsedSizeGit) All(r UsedSizeRepos) int64 {
return u.LFS + r.All()
}
type UsedSizeAssets struct {
Attachments UsedSizeAssetsAttachments
Artifacts int64
Packages UsedSizeAssetsPackages
}
func (u UsedSizeAssets) All() int64 {
return u.Attachments.All() + u.Artifacts + u.Packages.All
}
type UsedSizeAssetsAttachments struct {
Issues int64
Releases int64
}
func (u UsedSizeAssetsAttachments) All() int64 {
return u.Issues + u.Releases
}
type UsedSizeAssetsPackages struct {
All int64
}
func (u Used) CalculateFor(subject LimitSubject) int64 {
switch subject {
case LimitSubjectNone:
return 0
case LimitSubjectSizeAll:
return u.Size.All()
case LimitSubjectSizeReposAll:
return u.Size.Repos.All()
case LimitSubjectSizeReposPublic:
return u.Size.Repos.Public
case LimitSubjectSizeReposPrivate:
return u.Size.Repos.Private
case LimitSubjectSizeGitAll:
return u.Size.Git.All(u.Size.Repos)
case LimitSubjectSizeGitLFS:
return u.Size.Git.LFS
case LimitSubjectSizeAssetsAll:
return u.Size.Assets.All()
case LimitSubjectSizeAssetsAttachmentsAll:
return u.Size.Assets.Attachments.All()
case LimitSubjectSizeAssetsAttachmentsIssues:
return u.Size.Assets.Attachments.Issues
case LimitSubjectSizeAssetsAttachmentsReleases:
return u.Size.Assets.Attachments.Releases
case LimitSubjectSizeAssetsArtifacts:
return u.Size.Assets.Artifacts
case LimitSubjectSizeAssetsPackagesAll:
return u.Size.Assets.Packages.All
case LimitSubjectSizeWiki:
return 0
}
return 0
}
func makeUserOwnedCondition(q string, userID int64) builder.Cond {
switch q {
case "repositories", "attachments", "artifacts":
return builder.Eq{"`repository`.owner_id": userID}
case "packages":
return builder.Or(
builder.Eq{"`repository`.owner_id": userID},
builder.And(
builder.Eq{"`package`.repo_id": 0},
builder.Eq{"`package`.owner_id": userID},
),
)
}
return builder.NewCond()
}
func createQueryFor(ctx context.Context, userID int64, q string) db.Engine {
session := db.GetEngine(ctx)
switch q {
case "repositories":
session = session.Table("repository")
case "attachments":
session = session.
Table("attachment").
Join("INNER", "`repository`", "`attachment`.repo_id = `repository`.id")
case "artifacts":
session = session.
Table("action_artifact").
Join("INNER", "`repository`", "`action_artifact`.repo_id = `repository`.id")
case "packages":
session = session.
Table("package_version").
Join("INNER", "`package_file`", "`package_file`.version_id = `package_version`.id").
Join("INNER", "`package_blob`", "`package_file`.blob_id = `package_blob`.id").
Join("INNER", "`package`", "`package_version`.package_id = `package`.id").
Join("LEFT OUTER", "`repository`", "`package`.repo_id = `repository`.id")
}
return session.Where(makeUserOwnedCondition(q, userID))
}
func GetQuotaAttachmentsForUser(ctx context.Context, userID int64, opts db.ListOptions) (int64, *[]*repo_model.Attachment, error) {
var attachments []*repo_model.Attachment
sess := createQueryFor(ctx, userID, "attachments").
OrderBy("`attachment`.size DESC")
if opts.PageSize > 0 {
sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
}
count, err := sess.FindAndCount(&attachments)
if err != nil {
return 0, nil, err
}
return count, &attachments, nil
}
func GetQuotaPackagesForUser(ctx context.Context, userID int64, opts db.ListOptions) (int64, *[]*package_model.PackageVersion, error) {
var pkgs []*package_model.PackageVersion
sess := createQueryFor(ctx, userID, "packages").
OrderBy("`package_blob`.size DESC")
if opts.PageSize > 0 {
sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
}
count, err := sess.FindAndCount(&pkgs)
if err != nil {
return 0, nil, err
}
return count, &pkgs, nil
}
func GetQuotaArtifactsForUser(ctx context.Context, userID int64, opts db.ListOptions) (int64, *[]*action_model.ActionArtifact, error) {
var artifacts []*action_model.ActionArtifact
sess := createQueryFor(ctx, userID, "artifacts").
OrderBy("`action_artifact`.file_compressed_size DESC")
if opts.PageSize > 0 {
sess = sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
}
count, err := sess.FindAndCount(&artifacts)
if err != nil {
return 0, nil, err
}
return count, &artifacts, nil
}
func GetUsedForUser(ctx context.Context, userID int64) (*Used, error) {
var used Used
_, err := createQueryFor(ctx, userID, "repositories").
Where("`repository`.is_private = ?", true).
Select("SUM(git_size) AS code").
Get(&used.Size.Repos.Private)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "repositories").
Where("`repository`.is_private = ?", false).
Select("SUM(git_size) AS code").
Get(&used.Size.Repos.Public)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "repositories").
Select("SUM(lfs_size) AS lfs").
Get(&used.Size.Git.LFS)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "attachments").
Select("SUM(`attachment`.size) AS size").
Where("`attachment`.release_id != 0").
Get(&used.Size.Assets.Attachments.Releases)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "attachments").
Select("SUM(`attachment`.size) AS size").
Where("`attachment`.release_id = 0").
Get(&used.Size.Assets.Attachments.Issues)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "artifacts").
Select("SUM(file_compressed_size) AS size").
Get(&used.Size.Assets.Artifacts)
if err != nil {
return nil, err
}
_, err = createQueryFor(ctx, userID, "packages").
Select("SUM(package_blob.size) AS size").
Get(&used.Size.Assets.Packages.All)
if err != nil {
return nil, err
}
return &used, nil
}