diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
commit | dd136858f1ea40ad3c94191d647487fa4f31926c (patch) | |
tree | 58fec94a7b2a12510c9664b21793f1ed560c6518 /models/quota | |
parent | Initial commit. (diff) | |
download | forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.tar.xz forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.zip |
Adding upstream version 9.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'models/quota')
-rw-r--r-- | models/quota/default.go | 25 | ||||
-rw-r--r-- | models/quota/errors.go | 127 | ||||
-rw-r--r-- | models/quota/group.go | 401 | ||||
-rw-r--r-- | models/quota/limit_subject.go | 69 | ||||
-rw-r--r-- | models/quota/quota.go | 36 | ||||
-rw-r--r-- | models/quota/quota_group_test.go | 208 | ||||
-rw-r--r-- | models/quota/quota_rule_test.go | 304 | ||||
-rw-r--r-- | models/quota/rule.go | 127 | ||||
-rw-r--r-- | models/quota/used.go | 252 |
9 files changed, 1549 insertions, 0 deletions
diff --git a/models/quota/default.go b/models/quota/default.go new file mode 100644 index 0000000..6b553d6 --- /dev/null +++ b/models/quota/default.go @@ -0,0 +1,25 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package quota + +import ( + "code.gitea.io/gitea/modules/setting" +) + +func EvaluateDefault(used Used, forSubject LimitSubject) bool { + groups := GroupList{ + &Group{ + Name: "builtin-default-group", + Rules: []Rule{ + { + Name: "builtin-default-rule", + Limit: setting.Quota.Default.Total, + Subjects: LimitSubjects{LimitSubjectSizeAll}, + }, + }, + }, + } + + return groups.Evaluate(used, forSubject) +} diff --git a/models/quota/errors.go b/models/quota/errors.go new file mode 100644 index 0000000..962c8b1 --- /dev/null +++ b/models/quota/errors.go @@ -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) +} diff --git a/models/quota/group.go b/models/quota/group.go new file mode 100644 index 0000000..0acb5b2 --- /dev/null +++ b/models/quota/group.go @@ -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, use the configured defaults: + if gl == nil || len(*gl) == 0 { + return EvaluateDefault(used, forSubject) + } + + 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 +} diff --git a/models/quota/limit_subject.go b/models/quota/limit_subject.go new file mode 100644 index 0000000..4a49d33 --- /dev/null +++ b/models/quota/limit_subject.go @@ -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 +} diff --git a/models/quota/quota.go b/models/quota/quota.go new file mode 100644 index 0000000..d38bfab --- /dev/null +++ b/models/quota/quota.go @@ -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 +} diff --git a/models/quota/quota_group_test.go b/models/quota/quota_group_test.go new file mode 100644 index 0000000..bc25858 --- /dev/null +++ b/models/quota/quota_group_test.go @@ -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) +} diff --git a/models/quota/quota_rule_test.go b/models/quota/quota_rule_test.go new file mode 100644 index 0000000..1e1daf4 --- /dev/null +++ b/models/quota/quota_rule_test.go @@ -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) + }) +} diff --git a/models/quota/rule.go b/models/quota/rule.go new file mode 100644 index 0000000..b0c6c0f --- /dev/null +++ b/models/quota/rule.go @@ -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() +} diff --git a/models/quota/used.go b/models/quota/used.go new file mode 100644 index 0000000..ff84ac2 --- /dev/null +++ b/models/quota/used.go @@ -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 +} |