chore: scope workspace quotas to organizations (#14352)

* chore: scope workspace quotas to organizations

Quotas are now a function of (user_id, organization_id). They are
still sourced from groups. Deprecate the old api endpoint.
This commit is contained in:
Steven Masley
2024-08-21 09:25:20 -05:00
committed by GitHub
parent fa733318e0
commit a359879af5
16 changed files with 309 additions and 68 deletions

47
coderd/apidoc/docs.go generated
View File

@ -2813,6 +2813,48 @@ const docTemplate = `{
}
}
},
"/organizations/{organization}/members/{user}/workspace-quota": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get workspace quota by user",
"operationId": "get-workspace-quota-by-user",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceQuota"
}
}
}
}
},
"/organizations/{organization}/members/{user}/workspaces": {
"post": {
"security": [
@ -6258,8 +6300,9 @@ const docTemplate = `{
"tags": [
"Enterprise"
],
"summary": "Get workspace quota by user",
"operationId": "get-workspace-quota-by-user",
"summary": "Get workspace quota by user deprecated",
"operationId": "get-workspace-quota-by-user-deprecated",
"deprecated": true,
"parameters": [
{
"type": "string",

View File

@ -2465,6 +2465,44 @@
}
}
},
"/organizations/{organization}/members/{user}/workspace-quota": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get workspace quota by user",
"operationId": "get-workspace-quota-by-user",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.WorkspaceQuota"
}
}
}
}
},
"/organizations/{organization}/members/{user}/workspaces": {
"post": {
"security": [
@ -5524,8 +5562,9 @@
],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get workspace quota by user",
"operationId": "get-workspace-quota-by-user",
"summary": "Get workspace quota by user deprecated",
"operationId": "get-workspace-quota-by-user-deprecated",
"deprecated": true,
"parameters": [
{
"type": "string",

View File

@ -1823,20 +1823,20 @@ func (q *querier) GetProvisionerLogsAfterID(ctx context.Context, arg database.Ge
return q.db.GetProvisionerLogsAfterID(ctx, arg)
}
func (q *querier) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) {
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(userID))
func (q *querier) GetQuotaAllowanceForUser(ctx context.Context, params database.GetQuotaAllowanceForUserParams) (int64, error) {
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(params.UserID))
if err != nil {
return -1, err
}
return q.db.GetQuotaAllowanceForUser(ctx, userID)
return q.db.GetQuotaAllowanceForUser(ctx, params)
}
func (q *querier) GetQuotaConsumedForUser(ctx context.Context, userID uuid.UUID) (int64, error) {
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(userID))
func (q *querier) GetQuotaConsumedForUser(ctx context.Context, params database.GetQuotaConsumedForUserParams) (int64, error) {
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserObject(params.OwnerID))
if err != nil {
return -1, err
}
return q.db.GetQuotaConsumedForUser(ctx, userID)
return q.db.GetQuotaConsumedForUser(ctx, params)
}
func (q *querier) GetReplicaByID(ctx context.Context, id uuid.UUID) (database.Replica, error) {

View File

@ -1075,11 +1075,17 @@ func (s *MethodTestSuite) TestUser() {
}))
s.Run("GetQuotaAllowanceForUser", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
check.Args(u.ID).Asserts(u, policy.ActionRead).Returns(int64(0))
check.Args(database.GetQuotaAllowanceForUserParams{
UserID: u.ID,
OrganizationID: uuid.New(),
}).Asserts(u, policy.ActionRead).Returns(int64(0))
}))
s.Run("GetQuotaConsumedForUser", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
check.Args(u.ID).Asserts(u, policy.ActionRead).Returns(int64(0))
check.Args(database.GetQuotaConsumedForUserParams{
OwnerID: u.ID,
OrganizationID: uuid.New(),
}).Asserts(u, policy.ActionRead).Returns(int64(0))
}))
s.Run("GetUserByEmailOrUsername", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})

View File

@ -3314,13 +3314,13 @@ func (q *FakeQuerier) GetProvisionerLogsAfterID(_ context.Context, arg database.
return logs, nil
}
func (q *FakeQuerier) GetQuotaAllowanceForUser(_ context.Context, userID uuid.UUID) (int64, error) {
func (q *FakeQuerier) GetQuotaAllowanceForUser(_ context.Context, params database.GetQuotaAllowanceForUserParams) (int64, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
var sum int64
for _, member := range q.groupMembers {
if member.UserID != userID {
if member.UserID != params.UserID {
continue
}
if _, err := q.getOrganizationByIDNoLock(member.GroupID); err == nil {
@ -3340,7 +3340,7 @@ func (q *FakeQuerier) GetQuotaAllowanceForUser(_ context.Context, userID uuid.UU
// Grab the quota for the Everyone group iff the user is a member of
// said organization.
for _, mem := range q.organizationMembers {
if mem.UserID != userID {
if mem.UserID != params.UserID {
continue
}
@ -3348,19 +3348,25 @@ func (q *FakeQuerier) GetQuotaAllowanceForUser(_ context.Context, userID uuid.UU
if err != nil {
return -1, xerrors.Errorf("failed to get everyone group for org %q", mem.OrganizationID.String())
}
if group.OrganizationID != params.OrganizationID {
continue
}
sum += int64(group.QuotaAllowance)
}
return sum, nil
}
func (q *FakeQuerier) GetQuotaConsumedForUser(_ context.Context, userID uuid.UUID) (int64, error) {
func (q *FakeQuerier) GetQuotaConsumedForUser(_ context.Context, params database.GetQuotaConsumedForUserParams) (int64, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
var sum int64
for _, workspace := range q.workspaces {
if workspace.OwnerID != userID {
if workspace.OwnerID != params.OwnerID {
continue
}
if workspace.OrganizationID != params.OrganizationID {
continue
}
if workspace.Deleted {

View File

@ -963,14 +963,14 @@ func (m metricsStore) GetProvisionerLogsAfterID(ctx context.Context, arg databas
return logs, err
}
func (m metricsStore) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) {
func (m metricsStore) GetQuotaAllowanceForUser(ctx context.Context, userID database.GetQuotaAllowanceForUserParams) (int64, error) {
start := time.Now()
allowance, err := m.s.GetQuotaAllowanceForUser(ctx, userID)
m.queryLatencies.WithLabelValues("GetQuotaAllowanceForUser").Observe(time.Since(start).Seconds())
return allowance, err
}
func (m metricsStore) GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) {
func (m metricsStore) GetQuotaConsumedForUser(ctx context.Context, ownerID database.GetQuotaConsumedForUserParams) (int64, error) {
start := time.Now()
consumed, err := m.s.GetQuotaConsumedForUser(ctx, ownerID)
m.queryLatencies.WithLabelValues("GetQuotaConsumedForUser").Observe(time.Since(start).Seconds())

View File

@ -1960,7 +1960,7 @@ func (mr *MockStoreMockRecorder) GetProvisionerLogsAfterID(arg0, arg1 any) *gomo
}
// GetQuotaAllowanceForUser mocks base method.
func (m *MockStore) GetQuotaAllowanceForUser(arg0 context.Context, arg1 uuid.UUID) (int64, error) {
func (m *MockStore) GetQuotaAllowanceForUser(arg0 context.Context, arg1 database.GetQuotaAllowanceForUserParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetQuotaAllowanceForUser", arg0, arg1)
ret0, _ := ret[0].(int64)
@ -1975,7 +1975,7 @@ func (mr *MockStoreMockRecorder) GetQuotaAllowanceForUser(arg0, arg1 any) *gomoc
}
// GetQuotaConsumedForUser mocks base method.
func (m *MockStore) GetQuotaConsumedForUser(arg0 context.Context, arg1 uuid.UUID) (int64, error) {
func (m *MockStore) GetQuotaConsumedForUser(arg0 context.Context, arg1 database.GetQuotaConsumedForUserParams) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetQuotaConsumedForUser", arg0, arg1)
ret0, _ := ret[0].(int64)

View File

@ -194,8 +194,8 @@ type sqlcQuerier interface {
GetProvisionerKeyByID(ctx context.Context, id uuid.UUID) (ProvisionerKey, error)
GetProvisionerKeyByName(ctx context.Context, arg GetProvisionerKeyByNameParams) (ProvisionerKey, error)
GetProvisionerLogsAfterID(ctx context.Context, arg GetProvisionerLogsAfterIDParams) ([]ProvisionerJobLog, error)
GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error)
GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error)
GetQuotaAllowanceForUser(ctx context.Context, arg GetQuotaAllowanceForUserParams) (int64, error)
GetQuotaConsumedForUser(ctx context.Context, arg GetQuotaConsumedForUserParams) (int64, error)
GetReplicaByID(ctx context.Context, id uuid.UUID) (Replica, error)
GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error)
GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]TailnetAgent, error)

View File

@ -604,7 +604,10 @@ func TestWorkspaceQuotas(t *testing.T) {
db2sdk.List([]database.OrganizationMember{memOne, memTwo}, orgMemberIDs))
// Check the quota is correct.
allowance, err := db.GetQuotaAllowanceForUser(ctx, one.ID)
allowance, err := db.GetQuotaAllowanceForUser(ctx, database.GetQuotaAllowanceForUserParams{
UserID: one.ID,
OrganizationID: org.ID,
})
require.NoError(t, err)
require.Equal(t, int64(50), allowance)
@ -617,7 +620,10 @@ func TestWorkspaceQuotas(t *testing.T) {
require.NoError(t, err)
// Ensure allowance remains the same
allowance, err = db.GetQuotaAllowanceForUser(ctx, one.ID)
allowance, err = db.GetQuotaAllowanceForUser(ctx, database.GetQuotaAllowanceForUserParams{
UserID: one.ID,
OrganizationID: org.ID,
})
require.NoError(t, err)
require.Equal(t, int64(50), allowance)
})

View File

@ -6231,14 +6231,22 @@ FROM
(
-- Select all groups this user is a member of. This will also include
-- the "Everyone" group for organizations the user is a member of.
SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_theme_preference, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded WHERE $1 = user_id
SELECT user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_theme_preference, user_name, user_github_com_user_id, organization_id, group_name, group_id FROM group_members_expanded
WHERE
$1 = user_id AND
$2 = group_members_expanded.organization_id
) AS members
INNER JOIN groups ON
members.group_id = groups.id
`
func (q *sqlQuerier) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, getQuotaAllowanceForUser, userID)
type GetQuotaAllowanceForUserParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
}
func (q *sqlQuerier) GetQuotaAllowanceForUser(ctx context.Context, arg GetQuotaAllowanceForUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, getQuotaAllowanceForUser, arg.UserID, arg.OrganizationID)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
@ -6263,11 +6271,19 @@ FROM
workspaces
JOIN latest_builds ON
latest_builds.workspace_id = workspaces.id
WHERE NOT deleted AND workspaces.owner_id = $1
WHERE NOT
deleted AND
workspaces.owner_id = $1 AND
workspaces.organization_id = $2
`
func (q *sqlQuerier) GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, getQuotaConsumedForUser, ownerID)
type GetQuotaConsumedForUserParams struct {
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
}
func (q *sqlQuerier) GetQuotaConsumedForUser(ctx context.Context, arg GetQuotaConsumedForUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, getQuotaConsumedForUser, arg.OwnerID, arg.OrganizationID)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err

View File

@ -5,7 +5,10 @@ FROM
(
-- Select all groups this user is a member of. This will also include
-- the "Everyone" group for organizations the user is a member of.
SELECT * FROM group_members_expanded WHERE @user_id = user_id
SELECT * FROM group_members_expanded
WHERE
@user_id = user_id AND
@organization_id = group_members_expanded.organization_id
) AS members
INNER JOIN groups ON
members.group_id = groups.id
@ -30,4 +33,8 @@ FROM
workspaces
JOIN latest_builds ON
latest_builds.workspace_id = workspaces.id
WHERE NOT deleted AND workspaces.owner_id = $1;
WHERE NOT
deleted AND
workspaces.owner_id = @owner_id AND
workspaces.organization_id = @organization_id
;

View File

@ -572,8 +572,8 @@ type WorkspaceQuota struct {
Budget int `json:"budget"`
}
func (c *Client) WorkspaceQuota(ctx context.Context, userID string) (WorkspaceQuota, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspace-quota/%s", userID), nil)
func (c *Client) WorkspaceQuota(ctx context.Context, organizationID string, userID string) (WorkspaceQuota, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/%s/workspace-quota", organizationID, userID), nil)
if err != nil {
return WorkspaceQuota{}, err
}

View File

@ -1405,6 +1405,45 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get workspace quota by user
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members/{user}/workspace-quota \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /organizations/{organization}/members/{user}/workspace-quota`
### Parameters
| Name | In | Type | Required | Description |
| -------------- | ---- | ------------ | -------- | -------------------- |
| `user` | path | string | true | User ID, name, or me |
| `organization` | path | string(uuid) | true | Organization ID |
### Example responses
> 200 Response
```json
{
"budget": 0,
"credits_consumed": 0
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------ |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceQuota](schemas.md#codersdkworkspacequota) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get provisioner daemons
### Code samples
@ -2301,7 +2340,7 @@ Status Code **200**
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get workspace quota by user
## Get workspace quota by user deprecated
### Code samples

View File

@ -275,6 +275,19 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Delete("/organizations/{organization}/members/roles/{roleName}", api.deleteOrgRole)
})
r.Group(func(r chi.Router) {
r.Use(
apiKeyMiddleware,
httpmw.ExtractOrganizationParam(api.Database),
// Intentionally using ExtractUser instead of ExtractMember.
// It is possible for a member to be removed from an org, in which
// case their orphaned workspaces still exist. We only need
// the user_id for the query.
httpmw.ExtractUserParam(api.Database),
)
r.Get("/organizations/{organization}/members/{user}/workspace-quota", api.workspaceQuota)
})
r.Route("/organizations/{organization}/groups", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
@ -365,7 +378,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
)
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/", api.workspaceQuota)
r.Get("/", api.workspaceQuotaByUser)
})
})
r.Route("/appearance", func(r chi.Router) {

View File

@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"cdr.dev/slog"
@ -13,7 +14,6 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionerd/proto"
)
@ -48,12 +48,18 @@ func (c *committer) CommitQuota(
)
err = c.Database.InTx(func(s database.Store) error {
var err error
consumed, err = s.GetQuotaConsumedForUser(ctx, workspace.OwnerID)
consumed, err = s.GetQuotaConsumedForUser(ctx, database.GetQuotaConsumedForUserParams{
OwnerID: workspace.OwnerID,
OrganizationID: workspace.OrganizationID,
})
if err != nil {
return err
}
budget, err = s.GetQuotaAllowanceForUser(ctx, workspace.OwnerID)
budget, err = s.GetQuotaAllowanceForUser(ctx, database.GetQuotaAllowanceForUserParams{
UserID: workspace.OwnerID,
OrganizationID: workspace.OrganizationID,
})
if err != nil {
return err
}
@ -112,22 +118,43 @@ func (c *committer) CommitQuota(
}, nil
}
// @Summary Get workspace quota by user
// @ID get-workspace-quota-by-user
// @Summary Get workspace quota by user deprecated
// @ID get-workspace-quota-by-user-deprecated
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param user path string true "User ID, name, or me"
// @Success 200 {object} codersdk.WorkspaceQuota
// @Router /workspace-quota/{user} [get]
func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
if !api.AGPL.Authorize(r, policy.ActionRead, user) {
httpapi.ResourceNotFound(rw)
// @Deprecated this endpoint will be removed, use /organizations/{organization}/members/{user}/workspace-quota instead
func (api *API) workspaceQuotaByUser(rw http.ResponseWriter, r *http.Request) {
defaultOrg, err := api.Database.GetDefaultOrganization(r.Context())
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
// defer to the new endpoint using default org as the organization
chi.RouteContext(r.Context()).URLParams.Add("organization", defaultOrg.ID.String())
mw := httpmw.ExtractOrganizationParam(api.Database)
mw(http.HandlerFunc(api.workspaceQuota)).ServeHTTP(rw, r)
}
// @Summary Get workspace quota by user
// @ID get-workspace-quota-by-user
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param user path string true "User ID, name, or me"
// @Param organization path string true "Organization ID" format(uuid)
// @Success 200 {object} codersdk.WorkspaceQuota
// @Router /organizations/{organization}/members/{user}/workspace-quota [get]
func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
var (
organization = httpmw.OrganizationParam(r)
user = httpmw.UserParam(r)
)
api.entitlementsMu.RLock()
licensed := api.entitlements.Features[codersdk.FeatureTemplateRBAC].Enabled
api.entitlementsMu.RUnlock()
@ -136,7 +163,10 @@ func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
var quotaAllowance int64 = -1
if licensed {
var err error
quotaAllowance, err = api.Database.GetQuotaAllowanceForUser(r.Context(), user.ID)
quotaAllowance, err = api.Database.GetQuotaAllowanceForUser(r.Context(), database.GetQuotaAllowanceForUserParams{
UserID: user.ID,
OrganizationID: organization.ID,
})
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get allowance",
@ -146,7 +176,10 @@ func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
}
}
quotaConsumed, err := api.Database.GetQuotaConsumedForUser(r.Context(), user.ID)
quotaConsumed, err := api.Database.GetQuotaConsumedForUser(r.Context(), database.GetQuotaConsumedForUserParams{
OwnerID: user.ID,
OrganizationID: organization.ID,
})
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get consumed",

View File

@ -2,6 +2,9 @@ package coderd_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"testing"
@ -20,15 +23,31 @@ import (
"github.com/coder/coder/v2/testutil"
)
func verifyQuota(ctx context.Context, t *testing.T, client *codersdk.Client, consumed, total int) {
func verifyQuota(ctx context.Context, t *testing.T, client *codersdk.Client, organizationID string, consumed, total int) {
t.Helper()
got, err := client.WorkspaceQuota(ctx, codersdk.Me)
got, err := client.WorkspaceQuota(ctx, organizationID, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, codersdk.WorkspaceQuota{
Budget: total,
CreditsConsumed: consumed,
}, got)
// Remove this check when the deprecated endpoint is removed.
// This just makes sure the deprecated endpoint is still working
// as intended. It will only work for the default organization.
deprecatedGot, err := deprecatedQuotaEndpoint(ctx, client, codersdk.Me)
require.NoError(t, err, "deprecated endpoint")
// Only continue to check if the values differ
if deprecatedGot.Budget != got.Budget || deprecatedGot.CreditsConsumed != got.CreditsConsumed {
org, err := client.OrganizationByName(ctx, organizationID)
if err != nil {
return
}
if org.IsDefault {
require.Equal(t, got, deprecatedGot)
}
}
}
func TestWorkspaceQuota(t *testing.T) {
@ -52,14 +71,14 @@ func TestWorkspaceQuota(t *testing.T) {
})
coderdtest.NewProvisionerDaemon(t, api.AGPL)
verifyQuota(ctx, t, client, 0, 0)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 0)
// Patch the 'Everyone' group to verify its quota allowance is being accounted for.
_, err := client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{
QuotaAllowance: ptr.Ref(1),
})
require.NoError(t, err)
verifyQuota(ctx, t, client, 0, 1)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 1)
// Add user to two groups, granting them a total budget of 4.
group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
@ -84,7 +103,7 @@ func TestWorkspaceQuota(t *testing.T) {
})
require.NoError(t, err)
verifyQuota(ctx, t, client, 0, 4)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 4)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@ -123,14 +142,14 @@ func TestWorkspaceQuota(t *testing.T) {
}()
}
wg.Wait()
verifyQuota(ctx, t, client, 4, 4)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4)
// Next one must fail
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Consumed shouldn't bump
verifyQuota(ctx, t, client, 4, 4)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4)
require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status)
require.Contains(t, build.Job.Error, "quota")
@ -146,7 +165,7 @@ func TestWorkspaceQuota(t *testing.T) {
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
verifyQuota(ctx, t, client, 3, 4)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 3, 4)
break
}
@ -154,7 +173,7 @@ func TestWorkspaceQuota(t *testing.T) {
workspace = coderdtest.CreateWorkspace(t, client, template.ID)
build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
verifyQuota(ctx, t, client, 4, 4)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4)
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
})
@ -174,14 +193,14 @@ func TestWorkspaceQuota(t *testing.T) {
})
coderdtest.NewProvisionerDaemon(t, api.AGPL)
verifyQuota(ctx, t, client, 0, 0)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 0)
// Patch the 'Everyone' group to verify its quota allowance is being accounted for.
_, err := client.PatchGroup(ctx, user.OrganizationID, codersdk.PatchGroupRequest{
QuotaAllowance: ptr.Ref(4),
})
require.NoError(t, err)
verifyQuota(ctx, t, client, 0, 4)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 4)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
@ -208,7 +227,7 @@ func TestWorkspaceQuota(t *testing.T) {
assert.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
}
wg.Wait()
verifyQuota(ctx, t, client, 4, 4)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4)
// Next one must fail
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
@ -216,21 +235,21 @@ func TestWorkspaceQuota(t *testing.T) {
require.Contains(t, build.Job.Error, "quota")
// Consumed shouldn't bump
verifyQuota(ctx, t, client, 4, 4)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4)
require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status)
build = coderdtest.CreateWorkspaceBuild(t, client, workspaces[0], database.WorkspaceTransitionStop)
build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
// Quota goes down one
verifyQuota(ctx, t, client, 3, 4)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 3, 4)
require.Equal(t, codersdk.WorkspaceStatusStopped, build.Status)
build = coderdtest.CreateWorkspaceBuild(t, client, workspaces[0], database.WorkspaceTransitionStart)
build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
// Quota goes back up
verifyQuota(ctx, t, client, 4, 4)
verifyQuota(ctx, t, client, user.OrganizationID.String(), 4, 4)
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
})
@ -273,13 +292,27 @@ func TestWorkspaceQuota(t *testing.T) {
})
require.NoError(t, err)
verifyQuota(ctx, t, member, 0, 30)
// This currently reports the total site wide quotas. We might want to
// org scope this api call in the future.
verifyQuota(ctx, t, owner, 0, 45)
verifyQuota(ctx, t, member, first.OrganizationID.String(), 0, 30)
// Verify org scoped quota limits
verifyQuota(ctx, t, owner, first.OrganizationID.String(), 0, 30)
verifyQuota(ctx, t, owner, second.ID.String(), 0, 15)
})
}
func deprecatedQuotaEndpoint(ctx context.Context, client *codersdk.Client, userID string) (codersdk.WorkspaceQuota, error) {
res, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspace-quota/%s", userID), nil)
if err != nil {
return codersdk.WorkspaceQuota{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return codersdk.WorkspaceQuota{}, codersdk.ReadBodyAsError(res)
}
var quota codersdk.WorkspaceQuota
return quota, json.NewDecoder(res.Body).Decode(&quota)
}
func planWithCost(cost int32) []*proto.Response {
return []*proto.Response{{
Type: &proto.Response_Plan{