mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
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:
47
coderd/apidoc/docs.go
generated
47
coderd/apidoc/docs.go
generated
@ -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",
|
||||
|
43
coderd/apidoc/swagger.json
generated
43
coderd/apidoc/swagger.json
generated
@ -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",
|
||||
|
@ -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) {
|
||||
|
@ -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{})
|
||||
|
@ -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 {
|
||||
|
@ -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())
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
;
|
||||
|
@ -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
|
||||
}
|
||||
|
41
docs/reference/api/enterprise.md
generated
41
docs/reference/api/enterprise.md
generated
@ -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
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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",
|
||||
|
@ -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("a)
|
||||
}
|
||||
|
||||
func planWithCost(cost int32) []*proto.Response {
|
||||
return []*proto.Response{{
|
||||
Type: &proto.Response_Plan{
|
||||
|
Reference in New Issue
Block a user