mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Implement Quotas v3 (#5012)
* provisioner/terraform: add cost to resource_metadata * provisionerd/runner: use Options struct * Complete provisionerd implementation * Add quota_allowance to groups * Combine Quota and RBAC licenses * Add Opts to InTx
This commit is contained in:
@@ -109,6 +109,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
|
||||
"name": ActionTrack,
|
||||
"organization_id": ActionIgnore, // Never changes.
|
||||
"avatar_url": ActionTrack,
|
||||
"quota_allowance": ActionTrack,
|
||||
},
|
||||
// We don't show any diff for the WorkspaceBuild resource,
|
||||
// save for the template_version_id
|
||||
@@ -125,6 +126,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
|
||||
"job_id": ActionIgnore,
|
||||
"deadline": ActionIgnore,
|
||||
"reason": ActionIgnore,
|
||||
"daily_cost": ActionIgnore,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ func server() *cobra.Command {
|
||||
AuditLogging: options.DeploymentConfig.AuditLogging.Value,
|
||||
BrowserOnly: options.DeploymentConfig.BrowserOnly.Value,
|
||||
SCIMAPIKey: []byte(options.DeploymentConfig.SCIMAPIKey.Value),
|
||||
UserWorkspaceQuota: options.DeploymentConfig.UserWorkspaceQuota.Value,
|
||||
RBAC: true,
|
||||
DERPServerRelayAddress: options.DeploymentConfig.DERP.Server.RelayURL.Value,
|
||||
DERPServerRegionID: options.DeploymentConfig.DERP.Server.RegionID.Value,
|
||||
|
||||
+12
-10
@@ -20,12 +20,12 @@ import (
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/workspacequota"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/enterprise/coderd/license"
|
||||
"github.com/coder/coder/enterprise/derpmesh"
|
||||
"github.com/coder/coder/enterprise/replicasync"
|
||||
"github.com/coder/coder/enterprise/tailnet"
|
||||
"github.com/coder/coder/provisionerd/proto"
|
||||
agpltailnet "github.com/coder/coder/tailnet"
|
||||
)
|
||||
|
||||
@@ -113,7 +113,9 @@ func New(ctx context.Context, options *Options) (*API, error) {
|
||||
})
|
||||
|
||||
r.Route("/workspace-quota", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
)
|
||||
r.Route("/{user}", func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractUserParam(options.Database, false))
|
||||
r.Get("/", api.workspaceQuota)
|
||||
@@ -183,9 +185,8 @@ type Options struct {
|
||||
RBAC bool
|
||||
AuditLogging bool
|
||||
// Whether to block non-browser connections.
|
||||
BrowserOnly bool
|
||||
SCIMAPIKey []byte
|
||||
UserWorkspaceQuota int
|
||||
BrowserOnly bool
|
||||
SCIMAPIKey []byte
|
||||
|
||||
// Used for high availability.
|
||||
DERPServerRelayAddress string
|
||||
@@ -224,7 +225,6 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
codersdk.FeatureAuditLog: api.AuditLogging,
|
||||
codersdk.FeatureBrowserOnly: api.BrowserOnly,
|
||||
codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0,
|
||||
codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0,
|
||||
codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "",
|
||||
codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1,
|
||||
codersdk.FeatureTemplateRBAC: api.RBAC,
|
||||
@@ -262,12 +262,14 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
|
||||
}
|
||||
|
||||
if changed, enabled := featureChanged(codersdk.FeatureWorkspaceQuota); changed {
|
||||
enforcer := workspacequota.NewNop()
|
||||
if changed, enabled := featureChanged(codersdk.FeatureTemplateRBAC); changed {
|
||||
if enabled {
|
||||
enforcer = NewEnforcer(api.Options.UserWorkspaceQuota)
|
||||
committer := committer{Database: api.Database}
|
||||
ptr := proto.QuotaCommitter(&committer)
|
||||
api.AGPL.QuotaCommitter.Store(&ptr)
|
||||
} else {
|
||||
api.AGPL.QuotaCommitter.Store(nil)
|
||||
}
|
||||
api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer)
|
||||
}
|
||||
|
||||
if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed {
|
||||
|
||||
@@ -70,7 +70,6 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
|
||||
SCIMAPIKey: options.SCIMAPIKey,
|
||||
DERPServerRelayAddress: oop.AccessURL.String(),
|
||||
DERPServerRegionID: oop.DERPMap.RegionIDs()[0],
|
||||
UserWorkspaceQuota: options.UserWorkspaceQuota,
|
||||
Options: oop,
|
||||
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
|
||||
Keys: Keys,
|
||||
@@ -110,7 +109,6 @@ type LicenseOptions struct {
|
||||
AuditLog bool
|
||||
BrowserOnly bool
|
||||
SCIM bool
|
||||
WorkspaceQuota bool
|
||||
TemplateRBAC bool
|
||||
HighAvailability bool
|
||||
MultipleGitAuth bool
|
||||
@@ -145,10 +143,6 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
|
||||
if options.SCIM {
|
||||
scim = 1
|
||||
}
|
||||
var workspaceQuota int64
|
||||
if options.WorkspaceQuota {
|
||||
workspaceQuota = 1
|
||||
}
|
||||
highAvailability := int64(0)
|
||||
if options.HighAvailability {
|
||||
highAvailability = 1
|
||||
@@ -182,7 +176,6 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
|
||||
AuditLog: auditLog,
|
||||
BrowserOnly: browserOnly,
|
||||
SCIM: scim,
|
||||
WorkspaceQuota: workspaceQuota,
|
||||
HighAvailability: highAvailability,
|
||||
TemplateRBAC: rbacEnabled,
|
||||
MultipleGitAuth: multipleGitAuth,
|
||||
|
||||
+20
-12
@@ -53,6 +53,7 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request)
|
||||
Name: req.Name,
|
||||
OrganizationID: org.ID,
|
||||
AvatarURL: req.AvatarURL,
|
||||
QuotaAllowance: int32(req.QuotaAllowance),
|
||||
})
|
||||
if database.IsUniqueViolation(err) {
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
@@ -155,19 +156,25 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
return xerrors.Errorf("get group by ID: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Do we care about validating this?
|
||||
if req.AvatarURL != nil {
|
||||
group.AvatarURL = *req.AvatarURL
|
||||
}
|
||||
if req.Name != "" {
|
||||
group.Name = req.Name
|
||||
updateGroupParams := database.UpdateGroupByIDParams{
|
||||
ID: group.ID,
|
||||
AvatarURL: group.AvatarURL,
|
||||
Name: group.Name,
|
||||
QuotaAllowance: group.QuotaAllowance,
|
||||
}
|
||||
|
||||
group, err = tx.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{
|
||||
ID: group.ID,
|
||||
Name: group.Name,
|
||||
AvatarURL: group.AvatarURL,
|
||||
})
|
||||
// TODO: Do we care about validating this?
|
||||
if req.AvatarURL != nil {
|
||||
updateGroupParams.AvatarURL = *req.AvatarURL
|
||||
}
|
||||
if req.Name != "" {
|
||||
updateGroupParams.Name = req.Name
|
||||
}
|
||||
if req.QuotaAllowance != nil {
|
||||
updateGroupParams.QuotaAllowance = int32(*req.QuotaAllowance)
|
||||
}
|
||||
|
||||
group, err = tx.UpdateGroupByID(ctx, updateGroupParams)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update group by ID: %w", err)
|
||||
}
|
||||
@@ -188,7 +195,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, nil)
|
||||
if database.IsUniqueViolation(err) {
|
||||
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
|
||||
Message: "Cannot add the same user to a group twice!",
|
||||
@@ -327,6 +334,7 @@ func convertGroup(g database.Group, users []database.User) codersdk.Group {
|
||||
Name: g.Name,
|
||||
OrganizationID: g.OrganizationID,
|
||||
AvatarURL: g.AvatarURL,
|
||||
QuotaAllowance: int(g.QuotaAllowance),
|
||||
Members: convertUsers(users, orgs),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,18 +129,22 @@ func TestPatchGroup(t *testing.T) {
|
||||
})
|
||||
ctx, _ := testutil.Context(t)
|
||||
group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "hi",
|
||||
AvatarURL: "https://example.com",
|
||||
Name: "hi",
|
||||
AvatarURL: "https://example.com",
|
||||
QuotaAllowance: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 10, group.QuotaAllowance)
|
||||
|
||||
group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
|
||||
Name: "bye",
|
||||
AvatarURL: pointer.String("https://google.com"),
|
||||
Name: "bye",
|
||||
AvatarURL: pointer.String("https://google.com"),
|
||||
QuotaAllowance: pointer.Int(20),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "bye", group.Name)
|
||||
require.Equal(t, "https://google.com", group.AvatarURL)
|
||||
require.Equal(t, 20, group.QuotaAllowance)
|
||||
})
|
||||
|
||||
// The FE sends a request from the edit page where the old name == new name.
|
||||
|
||||
@@ -99,12 +99,6 @@ func Entitlements(
|
||||
Enabled: enablements[codersdk.FeatureSCIM],
|
||||
}
|
||||
}
|
||||
if claims.Features.WorkspaceQuota > 0 {
|
||||
entitlements.Features[codersdk.FeatureWorkspaceQuota] = codersdk.Feature{
|
||||
Entitlement: entitlement,
|
||||
Enabled: enablements[codersdk.FeatureWorkspaceQuota],
|
||||
}
|
||||
}
|
||||
if claims.Features.HighAvailability > 0 {
|
||||
entitlements.Features[codersdk.FeatureHighAvailability] = codersdk.Feature{
|
||||
Entitlement: entitlement,
|
||||
@@ -248,7 +242,6 @@ type Features struct {
|
||||
AuditLog int64 `json:"audit_log"`
|
||||
BrowserOnly int64 `json:"browser_only"`
|
||||
SCIM int64 `json:"scim"`
|
||||
WorkspaceQuota int64 `json:"workspace_quota"`
|
||||
TemplateRBAC int64 `json:"template_rbac"`
|
||||
HighAvailability int64 `json:"high_availability"`
|
||||
MultipleGitAuth int64 `json:"multiple_git_auth"`
|
||||
|
||||
@@ -23,7 +23,6 @@ func TestEntitlements(t *testing.T) {
|
||||
codersdk.FeatureAuditLog: true,
|
||||
codersdk.FeatureBrowserOnly: true,
|
||||
codersdk.FeatureSCIM: true,
|
||||
codersdk.FeatureWorkspaceQuota: true,
|
||||
codersdk.FeatureHighAvailability: true,
|
||||
codersdk.FeatureTemplateRBAC: true,
|
||||
codersdk.FeatureMultipleGitAuth: true,
|
||||
@@ -66,7 +65,6 @@ func TestEntitlements(t *testing.T) {
|
||||
AuditLog: true,
|
||||
BrowserOnly: true,
|
||||
SCIM: true,
|
||||
WorkspaceQuota: true,
|
||||
HighAvailability: true,
|
||||
TemplateRBAC: true,
|
||||
MultipleGitAuth: true,
|
||||
@@ -90,7 +88,6 @@ func TestEntitlements(t *testing.T) {
|
||||
AuditLog: true,
|
||||
BrowserOnly: true,
|
||||
SCIM: true,
|
||||
WorkspaceQuota: true,
|
||||
HighAvailability: true,
|
||||
TemplateRBAC: true,
|
||||
GraceAt: time.Now().Add(-time.Hour),
|
||||
|
||||
@@ -105,7 +105,6 @@ func TestGetLicense(t *testing.T) {
|
||||
codersdk.FeatureAuditLog: json.Number("1"),
|
||||
codersdk.FeatureSCIM: json.Number("1"),
|
||||
codersdk.FeatureBrowserOnly: json.Number("1"),
|
||||
codersdk.FeatureWorkspaceQuota: json.Number("0"),
|
||||
codersdk.FeatureHighAvailability: json.Number("0"),
|
||||
codersdk.FeatureTemplateRBAC: json.Number("1"),
|
||||
codersdk.FeatureMultipleGitAuth: json.Number("0"),
|
||||
@@ -118,7 +117,6 @@ func TestGetLicense(t *testing.T) {
|
||||
codersdk.FeatureAuditLog: json.Number("1"),
|
||||
codersdk.FeatureSCIM: json.Number("1"),
|
||||
codersdk.FeatureBrowserOnly: json.Number("1"),
|
||||
codersdk.FeatureWorkspaceQuota: json.Number("0"),
|
||||
codersdk.FeatureHighAvailability: json.Number("0"),
|
||||
codersdk.FeatureTemplateRBAC: json.Number("0"),
|
||||
codersdk.FeatureMultipleGitAuth: json.Number("0"),
|
||||
|
||||
@@ -171,7 +171,7 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) {
|
||||
return xerrors.Errorf("update template ACL by ID: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
|
||||
@@ -1,36 +1,102 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/workspacequota"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisionerd/proto"
|
||||
)
|
||||
|
||||
type enforcer struct {
|
||||
userWorkspaceLimit int
|
||||
type committer struct {
|
||||
Database database.Store
|
||||
}
|
||||
|
||||
func NewEnforcer(userWorkspaceLimit int) workspacequota.Enforcer {
|
||||
return &enforcer{
|
||||
userWorkspaceLimit: userWorkspaceLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *enforcer) UserWorkspaceLimit() int {
|
||||
return e.userWorkspaceLimit
|
||||
}
|
||||
|
||||
func (e *enforcer) CanCreateWorkspace(count int) bool {
|
||||
if e.userWorkspaceLimit == 0 {
|
||||
return true
|
||||
func (c *committer) CommitQuota(
|
||||
ctx context.Context, request *proto.CommitQuotaRequest,
|
||||
) (*proto.CommitQuotaResponse, error) {
|
||||
jobID, err := uuid.Parse(request.JobId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return count < e.userWorkspaceLimit
|
||||
build, err := c.Database.GetWorkspaceBuildByJobID(ctx, jobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
workspace, err := c.Database.GetWorkspaceByID(ctx, build.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
consumed int64
|
||||
budget int64
|
||||
permit bool
|
||||
)
|
||||
err = c.Database.InTx(func(s database.Store) error {
|
||||
var err error
|
||||
consumed, err = s.GetQuotaConsumedForUser(ctx, workspace.OwnerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
budget, err = s.GetQuotaAllowanceForUser(ctx, workspace.OwnerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the new build will reduce overall quota consumption, then we
|
||||
// allow it even if the user is over quota.
|
||||
netIncrease := true
|
||||
previousBuild, err := s.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{
|
||||
WorkspaceID: workspace.ID,
|
||||
BuildNumber: build.BuildNumber - 1,
|
||||
})
|
||||
if err == nil {
|
||||
if build.DailyCost < previousBuild.DailyCost {
|
||||
netIncrease = false
|
||||
}
|
||||
} else if !xerrors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
newConsumed := int64(request.DailyCost) + consumed
|
||||
if newConsumed > budget && netIncrease {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = s.UpdateWorkspaceBuildCostByID(ctx, database.UpdateWorkspaceBuildCostByIDParams{
|
||||
ID: build.ID,
|
||||
DailyCost: request.DailyCost,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permit = true
|
||||
consumed = newConsumed
|
||||
return nil
|
||||
}, &sql.TxOptions{
|
||||
Isolation: sql.LevelSerializable,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &proto.CommitQuotaResponse{
|
||||
Ok: permit,
|
||||
CreditsConsumed: int32(consumed),
|
||||
Budget: int32(budget),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -41,20 +107,35 @@ func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
workspaces, err := api.Database.GetWorkspaces(r.Context(), database.GetWorkspacesParams{
|
||||
OwnerID: user.ID,
|
||||
})
|
||||
api.entitlementsMu.RLock()
|
||||
licensed := api.entitlements.Features[codersdk.FeatureTemplateRBAC].Enabled
|
||||
api.entitlementsMu.RUnlock()
|
||||
|
||||
// There are no groups and thus no allowance if RBAC isn't licensed.
|
||||
var quotaAllowance int64 = -1
|
||||
if licensed {
|
||||
var err error
|
||||
quotaAllowance, err = api.Database.GetQuotaAllowanceForUser(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get allowance",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
quotaConsumed, err := api.Database.GetQuotaConsumedForUser(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspaces.",
|
||||
Message: "Failed to get consumed",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
e := *api.AGPL.WorkspaceQuotaEnforcer.Load()
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceQuota{
|
||||
UserWorkspaceCount: len(workspaces),
|
||||
UserWorkspaceLimit: e.UserWorkspaceLimit(),
|
||||
CreditsConsumed: int(quotaConsumed),
|
||||
Budget: int(quotaAllowance),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,13 +3,11 @@ package coderd_test
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/util/ptr"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
@@ -17,49 +15,22 @@ import (
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func verifyQuota(ctx context.Context, t *testing.T, client *codersdk.Client, consumed, total int) {
|
||||
t.Helper()
|
||||
|
||||
got, err := client.WorkspaceQuota(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceQuota{
|
||||
Budget: total,
|
||||
CreditsConsumed: consumed,
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestWorkspaceQuota(t *testing.T) {
|
||||
// TODO: refactor for new impl
|
||||
|
||||
t.Parallel()
|
||||
t.Run("Disabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
client := coderdenttest.New(t, &coderdenttest.Options{})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
WorkspaceQuota: true,
|
||||
})
|
||||
q1, err := client.WorkspaceQuota(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, q1.UserWorkspaceLimit, 0)
|
||||
})
|
||||
t.Run("Enabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
max := 3
|
||||
client := coderdenttest.New(t, &coderdenttest.Options{
|
||||
UserWorkspaceQuota: max,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
WorkspaceQuota: true,
|
||||
})
|
||||
q1, err := client.WorkspaceQuota(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, q1.UserWorkspaceLimit, max)
|
||||
|
||||
// ensure other user IDs work too
|
||||
u2, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
Email: "whatever@yo.com",
|
||||
Username: "haha",
|
||||
Password: "laskjdnvkaj",
|
||||
OrganizationID: user.OrganizationID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
q2, err := client.WorkspaceQuota(ctx, u2.ID.String())
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, q1, q2)
|
||||
})
|
||||
t.Run("BlocksBuild", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
@@ -71,14 +42,38 @@ func TestWorkspaceQuota(t *testing.T) {
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
})
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||
WorkspaceQuota: true,
|
||||
TemplateRBAC: true,
|
||||
})
|
||||
|
||||
verifyQuota(ctx, t, client, 0, 0)
|
||||
|
||||
// Add user to two groups, granting them a total budget of 3.
|
||||
group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "test-1",
|
||||
QuotaAllowance: 1,
|
||||
})
|
||||
q1, err := client.WorkspaceQuota(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, q1.UserWorkspaceCount, 0)
|
||||
require.EqualValues(t, q1.UserWorkspaceLimit, max)
|
||||
|
||||
group2, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
|
||||
Name: "test-2",
|
||||
QuotaAllowance: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.PatchGroup(ctx, group1.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{user.UserID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.PatchGroup(ctx, group2.ID, codersdk.PatchGroupRequest{
|
||||
AddUsers: []string{user.UserID.String()},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
verifyQuota(ctx, t, client, 0, 3)
|
||||
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
@@ -87,8 +82,9 @@ func TestWorkspaceQuota(t *testing.T) {
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
DailyCost: 1,
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Name: "example",
|
||||
@@ -103,20 +99,45 @@ func TestWorkspaceQuota(t *testing.T) {
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
_, err = client.CreateWorkspace(context.Background(), user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: "ajksdnvksjd",
|
||||
AutostartSchedule: ptr.Ref("CRON_TZ=US/Central 30 9 * * 1-5"),
|
||||
TTLMillis: ptr.Ref((8 * time.Hour).Milliseconds()),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "User workspace limit")
|
||||
|
||||
// ensure count increments
|
||||
q1, err = client.WorkspaceQuota(ctx, codersdk.Me)
|
||||
// Spin up three workspaces fine
|
||||
for i := 0; i < 3; i++ {
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
verifyQuota(ctx, t, client, i+1, 3)
|
||||
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
|
||||
}
|
||||
|
||||
// Next one must fail
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// Consumed shouldn't bump
|
||||
verifyQuota(ctx, t, client, 3, 3)
|
||||
require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status)
|
||||
require.Contains(t, build.Job.Error, "quota")
|
||||
|
||||
// Delete one random workspace, then quota should recover.
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, q1.UserWorkspaceCount, 1)
|
||||
require.EqualValues(t, q1.UserWorkspaceLimit, max)
|
||||
for _, w := range workspaces.Workspaces {
|
||||
if w.LatestBuild.Status != codersdk.WorkspaceStatusRunning {
|
||||
continue
|
||||
}
|
||||
build, err := client.CreateWorkspaceBuild(ctx, w.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
|
||||
verifyQuota(ctx, t, client, 2, 3)
|
||||
break
|
||||
}
|
||||
|
||||
// Next one should now succeed
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
build = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
verifyQuota(ctx, t, client, 3, 3)
|
||||
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user