feat: workspace quotas (#4184)

This commit is contained in:
Garrett Delfosse
2022-09-30 14:01:20 -04:00
committed by GitHub
parent f9b7588963
commit 69c73b2d28
28 changed files with 712 additions and 83 deletions
+10 -2
View File
@@ -35,6 +35,7 @@ import (
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/workspacequota"
"github.com/coder/coder/coderd/wsconncache"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/site"
@@ -55,6 +56,7 @@ type Options struct {
CacheDir string
Auditor audit.Auditor
WorkspaceQuotaEnforcer workspacequota.Enforcer
AgentConnectionUpdateFrequency time.Duration
AgentInactiveDisconnectTimeout time.Duration
// APIRateLimit is the minutely throughput rate limit per user or ip.
@@ -120,6 +122,9 @@ func New(options *Options) *API {
if options.Auditor == nil {
options.Auditor = audit.NewNop()
}
if options.WorkspaceQuotaEnforcer == nil {
options.WorkspaceQuotaEnforcer = workspacequota.NewNop()
}
siteCacheDir := options.CacheDir
if siteCacheDir != "" {
@@ -145,10 +150,12 @@ func New(options *Options) *API {
Authorizer: options.Authorizer,
Logger: options.Logger,
},
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
WorkspaceQuotaEnforcer: atomic.Pointer[workspacequota.Enforcer]{},
}
api.Auditor.Store(&options.Auditor)
api.WorkspaceQuotaEnforcer.Store(&options.WorkspaceQuotaEnforcer)
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
api.derpServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger))
oauthConfigs := &httpmw.OAuth2Configs{
@@ -516,6 +523,7 @@ type API struct {
*Options
Auditor atomic.Pointer[audit.Auditor]
WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool]
WorkspaceQuotaEnforcer atomic.Pointer[workspacequota.Enforcer]
HTTPAuth *HTTPAuthorizer
// APIHandler serves "/api/v2"
@@ -698,6 +698,22 @@ func (q *fakeQuerier) GetWorkspaceBuildByID(_ context.Context, id uuid.UUID) (da
return database.WorkspaceBuild{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspaceCountByUserID(_ context.Context, id uuid.UUID) (int64, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
var count int64
for _, workspace := range q.workspaces {
if workspace.OwnerID.String() == id.String() {
if workspace.Deleted {
continue
}
count++
}
}
return count, nil
}
func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUID) (database.WorkspaceBuild, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
+1
View File
@@ -92,6 +92,7 @@ type querier interface {
GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error)
GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error)
GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error)
GetWorkspaceCountByUserID(ctx context.Context, ownerID uuid.UUID) (int64, error)
GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error)
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
GetWorkspaceResourceMetadataByResourceID(ctx context.Context, workspaceResourceID uuid.UUID) ([]WorkspaceResourceMetadatum, error)
+18
View File
@@ -4971,6 +4971,24 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo
return i, err
}
const getWorkspaceCountByUserID = `-- name: GetWorkspaceCountByUserID :one
SELECT
COUNT(id)
FROM
workspaces
WHERE
owner_id = $1
-- Ignore deleted workspaces
AND deleted != true
`
func (q *sqlQuerier) GetWorkspaceCountByUserID(ctx context.Context, ownerID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, getWorkspaceCountByUserID, ownerID)
var count int64
err := row.Scan(&count)
return count, err
}
const getWorkspaceOwnerCountsByTemplateIDs = `-- name: GetWorkspaceOwnerCountsByTemplateIDs :many
SELECT
template_id,
+10
View File
@@ -74,6 +74,16 @@ WHERE
GROUP BY
template_id;
-- name: GetWorkspaceCountByUserID :one
SELECT
COUNT(id)
FROM
workspaces
WHERE
owner_id = @owner_id
-- Ignore deleted workspaces
AND deleted != true;
-- name: InsertWorkspace :one
INSERT INTO
workspaces (
+19
View File
@@ -0,0 +1,19 @@
package workspacequota
type Enforcer interface {
UserWorkspaceLimit() int
CanCreateWorkspace(count int) bool
}
type nop struct{}
func NewNop() Enforcer {
return &nop{}
}
func (*nop) UserWorkspaceLimit() int {
return 0
}
func (*nop) CanCreateWorkspace(_ int) bool {
return true
}
+23 -2
View File
@@ -317,6 +317,25 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
return
}
workspaceCount, err := api.Database.GetWorkspaceCountByUserID(ctx, user.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace count.",
Detail: err.Error(),
})
return
}
// make sure the user has not hit their quota limit
e := *api.WorkspaceQuotaEnforcer.Load()
canCreate := e.CanCreateWorkspace(int(workspaceCount))
if !canCreate {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("User workspace limit of %d is already reached.", e.UserWorkspaceLimit()),
})
return
}
templateVersion, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@@ -352,8 +371,10 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
return
}
var provisionerJob database.ProvisionerJob
var workspaceBuild database.WorkspaceBuild
var (
provisionerJob database.ProvisionerJob
workspaceBuild database.WorkspaceBuild
)
err = api.Database.InTx(func(db database.Store) error {
now := database.Now()
workspaceBuildID := uuid.New()
+12 -5
View File
@@ -15,13 +15,20 @@ const (
)
const (
FeatureUserLimit = "user_limit"
FeatureAuditLog = "audit_log"
FeatureBrowserOnly = "browser_only"
FeatureSCIM = "scim"
FeatureUserLimit = "user_limit"
FeatureAuditLog = "audit_log"
FeatureBrowserOnly = "browser_only"
FeatureSCIM = "scim"
FeatureWorkspaceQuota = "workspace_quota"
)
var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureBrowserOnly, FeatureSCIM}
var FeatureNames = []string{
FeatureUserLimit,
FeatureAuditLog,
FeatureBrowserOnly,
FeatureSCIM,
FeatureWorkspaceQuota,
}
type Feature struct {
Entitlement Entitlement `json:"entitlement"`
+26
View File
@@ -0,0 +1,26 @@
package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
type WorkspaceQuota struct {
UserWorkspaceCount int `json:"user_workspace_count"`
UserWorkspaceLimit int `json:"user_workspace_limit"`
}
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)
if err != nil {
return WorkspaceQuota{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceQuota{}, readBodyAsError(res)
}
var quota WorkspaceQuota
return quota, json.NewDecoder(res.Body).Decode(&quota)
}
+3 -1
View File
@@ -57,7 +57,7 @@ func TestFeaturesList(t *testing.T) {
var entitlements codersdk.Entitlements
err := json.Unmarshal(buf.Bytes(), &entitlements)
require.NoError(t, err, "unmarshal JSON output")
assert.Len(t, entitlements.Features, 3)
assert.Len(t, entitlements.Features, 4)
assert.Empty(t, entitlements.Warnings)
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureUserLimit].Entitlement)
@@ -65,6 +65,8 @@ func TestFeaturesList(t *testing.T) {
entitlements.Features[codersdk.FeatureAuditLog].Entitlement)
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement)
assert.Equal(t, codersdk.EntitlementNotEntitled,
entitlements.Features[codersdk.FeatureWorkspaceQuota].Entitlement)
assert.False(t, entitlements.HasLicense)
})
}
+11 -7
View File
@@ -15,16 +15,18 @@ import (
func server() *cobra.Command {
var (
auditLogging bool
browserOnly bool
scimAuthHeader string
auditLogging bool
browserOnly bool
scimAuthHeader string
userWorkspaceQuota int
)
cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) {
api, err := coderd.New(ctx, &coderd.Options{
AuditLogging: auditLogging,
BrowserOnly: browserOnly,
SCIMAPIKey: []byte(scimAuthHeader),
Options: options,
AuditLogging: auditLogging,
BrowserOnly: browserOnly,
SCIMAPIKey: []byte(scimAuthHeader),
UserWorkspaceQuota: userWorkspaceQuota,
Options: options,
})
if err != nil {
return nil, err
@@ -39,6 +41,8 @@ func server() *cobra.Command {
"Whether Coder only allows connections to workspaces via the browser. "+enterpriseOnly)
cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "",
"Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication. "+enterpriseOnly)
cliflag.IntVarP(cmd.Flags(), &userWorkspaceQuota, "user-workspace-quota", "", "CODER_USER_WORKSPACE_QUOTA", 0,
"A positive number applies a limit on how many workspaces each user can create. "+enterpriseOnly)
return cmd
}
+46 -17
View File
@@ -15,7 +15,6 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/coderd"
agplaudit "github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
@@ -43,9 +42,10 @@ func New(ctx context.Context, options *Options) (*API, error) {
Entitlement: codersdk.EntitlementNotEntitled,
Enabled: false,
},
auditLogs: codersdk.EntitlementNotEntitled,
browserOnly: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
auditLogs: codersdk.EntitlementNotEntitled,
browserOnly: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
workspaceQuota: codersdk.EntitlementNotEntitled,
},
cancelEntitlementsLoop: cancelFunc,
}
@@ -67,6 +67,13 @@ func New(ctx context.Context, options *Options) (*API, error) {
r.Get("/", api.licenses)
r.Delete("/{id}", api.deleteLicense)
})
r.Route("/workspace-quota", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Get("/", api.workspaceQuota)
})
})
})
if len(options.SCIMAPIKey) != 0 {
@@ -96,8 +103,10 @@ type Options struct {
AuditLogging bool
// Whether to block non-browser connections.
BrowserOnly bool
SCIMAPIKey []byte
BrowserOnly bool
SCIMAPIKey []byte
UserWorkspaceQuota int
EntitlementsUpdateInterval time.Duration
Keys map[string]ed25519.PublicKey
}
@@ -112,11 +121,12 @@ type API struct {
}
type entitlements struct {
hasLicense bool
activeUsers codersdk.Feature
auditLogs codersdk.Entitlement
browserOnly codersdk.Entitlement
scim codersdk.Entitlement
hasLicense bool
activeUsers codersdk.Feature
auditLogs codersdk.Entitlement
browserOnly codersdk.Entitlement
scim codersdk.Entitlement
workspaceQuota codersdk.Entitlement
}
func (api *API) Close() error {
@@ -140,9 +150,10 @@ func (api *API) updateEntitlements(ctx context.Context) error {
Enabled: false,
Entitlement: codersdk.EntitlementNotEntitled,
},
auditLogs: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
browserOnly: codersdk.EntitlementNotEntitled,
auditLogs: codersdk.EntitlementNotEntitled,
scim: codersdk.EntitlementNotEntitled,
browserOnly: codersdk.EntitlementNotEntitled,
workspaceQuota: codersdk.EntitlementNotEntitled,
}
// Here we loop through licenses to detect enabled features.
@@ -181,20 +192,22 @@ func (api *API) updateEntitlements(ctx context.Context) error {
if claims.Features.SCIM > 0 {
entitlements.scim = entitlement
}
if claims.Features.WorkspaceQuota > 0 {
entitlements.workspaceQuota = entitlement
}
}
if entitlements.auditLogs != api.entitlements.auditLogs {
auditor := agplaudit.NewNop()
// A flag could be added to the options that would allow disabling
// enhanced audit logging here!
if entitlements.auditLogs != codersdk.EntitlementNotEntitled && api.AuditLogging {
auditor = audit.NewAuditor(
auditor := audit.NewAuditor(
audit.DefaultFilter,
backends.NewPostgres(api.Database, true),
backends.NewSlog(api.Logger),
)
api.AGPL.Auditor.Store(&auditor)
}
api.AGPL.Auditor.Store(&auditor)
}
if entitlements.browserOnly != api.entitlements.browserOnly {
@@ -205,6 +218,13 @@ func (api *API) updateEntitlements(ctx context.Context) error {
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
}
if entitlements.workspaceQuota != api.entitlements.workspaceQuota {
if entitlements.workspaceQuota != codersdk.EntitlementNotEntitled && api.UserWorkspaceQuota > 0 {
enforcer := NewEnforcer(api.Options.UserWorkspaceQuota)
api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer)
}
}
api.entitlements = entitlements
return nil
@@ -260,6 +280,15 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) {
"Browser only connections are enabled but your license for this feature is expired.")
}
resp.Features[codersdk.FeatureWorkspaceQuota] = codersdk.Feature{
Entitlement: entitlements.workspaceQuota,
Enabled: api.UserWorkspaceQuota > 0,
}
if entitlements.workspaceQuota == codersdk.EntitlementGracePeriod && api.UserWorkspaceQuota > 0 {
resp.Warnings = append(resp.Warnings,
"Workspace quotas are enabled but your license for this feature is expired.")
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
@@ -39,6 +39,7 @@ type Options struct {
BrowserOnly bool
EntitlementsUpdateInterval time.Duration
SCIMAPIKey []byte
UserWorkspaceQuota int
}
// New constructs a codersdk client connected to an in-memory Enterprise API instance.
@@ -59,6 +60,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
AuditLogging: true,
BrowserOnly: options.BrowserOnly,
SCIMAPIKey: options.SCIMAPIKey,
UserWorkspaceQuota: options.UserWorkspaceQuota,
Options: oop,
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
Keys: map[string]ed25519.PublicKey{
@@ -80,14 +82,15 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
}
type LicenseOptions struct {
AccountType string
AccountID string
GraceAt time.Time
ExpiresAt time.Time
UserLimit int64
AuditLog bool
BrowserOnly bool
SCIM bool
AccountType string
AccountID string
GraceAt time.Time
ExpiresAt time.Time
UserLimit int64
AuditLog bool
BrowserOnly bool
SCIM bool
WorkspaceQuota bool
}
// AddLicense generates a new license with the options provided and inserts it.
@@ -119,6 +122,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
if options.SCIM {
scim = 1
}
workspaceQuota := int64(0)
if options.WorkspaceQuota {
workspaceQuota = 1
}
c := &coderd.Claims{
RegisteredClaims: jwt.RegisteredClaims{
@@ -132,10 +139,11 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
AccountID: options.AccountID,
Version: coderd.CurrentVersion,
Features: coderd.Features{
UserLimit: options.UserLimit,
AuditLog: auditLog,
BrowserOnly: browserOnly,
SCIM: scim,
UserLimit: options.UserLimit,
AuditLog: auditLog,
BrowserOnly: browserOnly,
SCIM: scim,
WorkspaceQuota: workspaceQuota,
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)
+5 -4
View File
@@ -45,10 +45,11 @@ var key20220812 []byte
var Keys = map[string]ed25519.PublicKey{"2022-08-12": ed25519.PublicKey(key20220812)}
type Features struct {
UserLimit int64 `json:"user_limit"`
AuditLog int64 `json:"audit_log"`
BrowserOnly int64 `json:"browser_only"`
SCIM int64 `json:"scim"`
UserLimit int64 `json:"user_limit"`
AuditLog int64 `json:"audit_log"`
BrowserOnly int64 `json:"browser_only"`
SCIM int64 `json:"scim"`
WorkspaceQuota int64 `json:"workspace_quota"`
}
type Claims struct {
+10 -8
View File
@@ -98,18 +98,20 @@ func TestGetLicense(t *testing.T) {
assert.Equal(t, int32(1), licenses[0].ID)
assert.Equal(t, "testing", licenses[0].Claims["account_id"])
assert.Equal(t, map[string]interface{}{
codersdk.FeatureUserLimit: json.Number("0"),
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureBrowserOnly: json.Number("1"),
codersdk.FeatureUserLimit: json.Number("0"),
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureBrowserOnly: json.Number("1"),
codersdk.FeatureWorkspaceQuota: json.Number("0"),
}, licenses[0].Claims["features"])
assert.Equal(t, int32(2), licenses[1].ID)
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
assert.Equal(t, map[string]interface{}{
codersdk.FeatureUserLimit: json.Number("200"),
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureBrowserOnly: json.Number("1"),
codersdk.FeatureUserLimit: json.Number("200"),
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureBrowserOnly: json.Number("1"),
codersdk.FeatureWorkspaceQuota: json.Number("0"),
}, licenses[1].Claims["features"])
})
}
+60
View File
@@ -0,0 +1,60 @@
package coderd
import (
"net/http"
"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"
)
type enforcer struct {
userWorkspaceLimit int
}
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
}
return count < e.userWorkspaceLimit
}
func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
if !api.AGPL.Authorize(r, rbac.ActionRead, rbac.ResourceUser) {
httpapi.ResourceNotFound(rw)
return
}
workspaces, err := api.Database.GetWorkspaces(r.Context(), database.GetWorkspacesParams{
OwnerID: user.ID,
})
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspaces.",
Detail: err.Error(),
})
return
}
e := *api.AGPL.WorkspaceQuotaEnforcer.Load()
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceQuota{
UserWorkspaceCount: len(workspaces),
UserWorkspaceLimit: e.UserWorkspaceLimit(),
})
}
+122
View File
@@ -0,0 +1,122 @@
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"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/testutil"
)
func TestWorkspaceQuota(t *testing.T) {
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)
defer cancel()
max := 1
client := coderdenttest.New(t, &coderdenttest.Options{
UserWorkspaceQuota: max,
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
})
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.UserWorkspaceCount, 0)
require.EqualValues(t, q1.UserWorkspaceLimit, max)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
Provision: []*proto.Provision_Response{{
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "example",
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
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)
require.NoError(t, err)
require.EqualValues(t, q1.UserWorkspaceCount, 1)
require.EqualValues(t, q1.UserWorkspaceLimit, max)
})
}
+5
View File
@@ -500,3 +500,8 @@ export const getApplicationsHost = async (): Promise<TypesGen.GetAppHostResponse
const response = await axios.get(`/api/v2/applications/host`)
return response.data
}
export const getWorkspaceQuota = async (userID: string): Promise<TypesGen.WorkspaceQuota> => {
const response = await axios.get(`/api/v2/workspace-quota/${userID}`)
return response.data
}
+2
View File
@@ -20,4 +20,6 @@ export enum FeatureNames {
AuditLog = "audit_log",
UserLimit = "user_limit",
BrowserOnly = "browser_only",
SCIM = "scim",
WorkspaceQuota = "workspace_quota",
}
+6
View File
@@ -641,6 +641,12 @@ export interface WorkspaceOptions {
readonly include_deleted?: boolean
}
// From codersdk/workspacequota.go
export interface WorkspaceQuota {
readonly user_workspace_count: number
readonly user_workspace_limit: number
}
// From codersdk/workspaceresources.go
export interface WorkspaceResource {
readonly id: string
@@ -12,6 +12,7 @@ export interface FormFooterProps {
onCancel: () => void
isLoading: boolean
submitLabel?: string
submitDisabled?: boolean
}
const useStyles = makeStyles((theme) => ({
@@ -34,6 +35,7 @@ export const FormFooter: FC<React.PropsWithChildren<FormFooterProps>> = ({
onCancel,
isLoading,
submitLabel = Language.defaultSubmitLabel,
submitDisabled,
}) => {
const styles = useStyles()
return (
@@ -45,6 +47,7 @@ export const FormFooter: FC<React.PropsWithChildren<FormFooterProps>> = ({
variant="contained"
color="primary"
type="submit"
disabled={submitDisabled}
>
{submitLabel}
</LoadingButton>
@@ -0,0 +1,51 @@
import { Story } from "@storybook/react"
import { WorkspaceQuota, WorkspaceQuotaProps } from "./WorkspaceQuota"
export default {
title: "components/WorkspaceQuota",
component: WorkspaceQuota,
}
const Template: Story<WorkspaceQuotaProps> = (args) => <WorkspaceQuota {...args} />
export const Example = Template.bind({})
Example.args = {
quota: {
user_workspace_count: 1,
user_workspace_limit: 3,
},
}
export const LimitOf1 = Template.bind({})
LimitOf1.args = {
quota: {
user_workspace_count: 1,
user_workspace_limit: 1,
},
}
export const Loading = Template.bind({})
Loading.args = {
quota: undefined,
}
export const Error = Template.bind({})
Error.args = {
quota: undefined,
error: {
response: {
data: {
message: "Failed to fetch workspace quotas!",
},
},
isAxiosError: true,
},
}
export const Disabled = Template.bind({})
Disabled.args = {
quota: {
user_workspace_count: 1,
user_workspace_limit: 0,
},
}
@@ -0,0 +1,114 @@
import Box from "@material-ui/core/Box"
import LinearProgress from "@material-ui/core/LinearProgress"
import { makeStyles } from "@material-ui/core/styles"
import Skeleton from "@material-ui/lab/Skeleton"
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { Stack } from "components/Stack/Stack"
import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
export const Language = {
of: "of",
workspace: "workspace",
workspaces: "workspaces",
}
export interface WorkspaceQuotaProps {
quota?: TypesGen.WorkspaceQuota
error: Error | unknown
}
export const WorkspaceQuota: FC<WorkspaceQuotaProps> = ({ quota, error }) => {
const styles = useStyles()
// error state
if (error !== undefined) {
return (
<Box>
<Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Workspace Quota</span>
<ErrorSummary error={error} />
</Stack>
</Box>
)
}
// loading
if (quota === undefined) {
return (
<Box>
<Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Workspace Quota</span>
<LinearProgress color="primary" />
<div className={styles.label}>
<Skeleton className={styles.skeleton} />
</div>
</Stack>
</Box>
)
}
// don't show if limit is 0, this means the feature is disabled.
if (quota.user_workspace_limit === 0) {
return null
}
let value = Math.round((quota.user_workspace_count / quota.user_workspace_limit) * 100)
// we don't want to round down to zero if the count is > 0
if (quota.user_workspace_count > 0 && value === 0) {
value = 1
}
return (
<Box>
<Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Workspace Quota</span>
<LinearProgress
className={
quota.user_workspace_count >= quota.user_workspace_limit
? styles.maxProgress
: undefined
}
value={value}
variant="determinate"
/>
<div className={styles.label}>
{quota.user_workspace_count} {Language.of} {quota.user_workspace_limit}{" "}
{quota.user_workspace_limit === 1 ? Language.workspace : Language.workspaces}
{" used"}
</div>
</Stack>
</Box>
)
}
const useStyles = makeStyles((theme) => ({
stack: {
paddingTop: theme.spacing(2.5),
},
maxProgress: {
"& .MuiLinearProgress-colorPrimary": {
backgroundColor: theme.palette.error.main,
},
"& .MuiLinearProgress-barColorPrimary": {
backgroundColor: theme.palette.error.main,
},
},
title: {
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 21,
paddingBottom: "8px",
},
label: {
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 12,
textTransform: "uppercase",
display: "block",
fontWeight: 600,
color: theme.palette.text.secondary,
},
skeleton: {
minWidth: "150px",
},
}))
@@ -4,7 +4,13 @@ import userEvent from "@testing-library/user-event"
import * as API from "api/api"
import { Language as FooterLanguage } from "components/FormFooter/FormFooter"
import i18next from "i18next"
import { MockTemplate, MockUser, MockWorkspace, MockWorkspaceRequest } from "testHelpers/entities"
import {
MockTemplate,
MockUser,
MockWorkspace,
MockWorkspaceQuota,
MockWorkspaceRequest,
} from "testHelpers/entities"
import { renderWithAuth } from "testHelpers/renderHelpers"
import CreateWorkspacePage from "./CreateWorkspacePage"
@@ -28,6 +34,7 @@ describe("CreateWorkspacePage", () => {
it("succeeds with default owner", async () => {
jest.spyOn(API, "getUsers").mockResolvedValueOnce([MockUser])
jest.spyOn(API, "getWorkspaceQuota").mockResolvedValueOnce(MockWorkspaceQuota)
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)
renderCreateWorkspacePage()
@@ -1,21 +1,32 @@
import { useActor, useMachine } from "@xstate/react"
import { User } from "api/typesGenerated"
import { shallowEqual, useActor, useMachine, useSelector } from "@xstate/react"
import { FeatureNames } from "api/types"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC, useContext, useState } from "react"
import { FC, useContext } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate, useParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { createWorkspaceMachine } from "xServices/createWorkspace/createWorkspaceXService"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { XServiceContext } from "xServices/StateContext"
import { CreateWorkspaceErrors, CreateWorkspacePageView } from "./CreateWorkspacePageView"
const CreateWorkspacePage: FC = () => {
const xServices = useContext(XServiceContext)
const organizationId = useOrganizationId()
const { template } = useParams()
const templateName = template ? template : ""
const navigate = useNavigate()
const featureVisibility = useSelector(
xServices.entitlementsXService,
selectFeatureVisibility,
shallowEqual,
)
const workspaceQuotaEnabled = featureVisibility[FeatureNames.WorkspaceQuota]
const [authState] = useActor(xServices.authXService)
const { me } = authState.context
const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, {
context: { organizationId, templateName },
context: { organizationId, templateName, workspaceQuotaEnabled, owner: me ?? null },
actions: {
onCreateWorkspace: (_, event) => {
navigate(`/@${event.data.owner_name}/${event.data.name}`)
@@ -31,14 +42,11 @@ const CreateWorkspacePage: FC = () => {
getTemplatesError,
createWorkspaceError,
permissions,
workspaceQuota,
getWorkspaceQuotaError,
owner,
} = createWorkspaceState.context
const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService)
const { me } = authState.context
const [owner, setOwner] = useState<User | null>(me ?? null)
return (
<>
<Helmet>
@@ -53,14 +61,21 @@ const CreateWorkspacePage: FC = () => {
templates={templates}
selectedTemplate={selectedTemplate}
templateSchema={templateSchema}
workspaceQuota={workspaceQuota}
createWorkspaceErrors={{
[CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: getTemplatesError,
[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: getTemplateSchemaError,
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: createWorkspaceError,
[CreateWorkspaceErrors.GET_WORKSPACE_QUOTA_ERROR]: getWorkspaceQuotaError,
}}
canCreateForUser={permissions?.createWorkspaceForUser}
defaultWorkspaceOwner={me ?? null}
setOwner={setOwner}
owner={owner}
setOwner={(user) => {
send({
type: "SELECT_OWNER",
owner: user,
})
}}
onCancel={() => {
navigate("/templates")
}}
@@ -7,6 +7,7 @@ import { Loader } from "components/Loader/Loader"
import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { Stack } from "components/Stack/Stack"
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { i18n } from "i18n"
import { FC, useState } from "react"
@@ -18,6 +19,7 @@ export enum CreateWorkspaceErrors {
GET_TEMPLATES_ERROR = "getTemplatesError",
GET_TEMPLATE_SCHEMA_ERROR = "getTemplateSchemaError",
CREATE_WORKSPACE_ERROR = "createWorkspaceError",
GET_WORKSPACE_QUOTA_ERROR = "getWorkspaceQuotaError",
}
export interface CreateWorkspacePageViewProps {
@@ -29,9 +31,10 @@ export interface CreateWorkspacePageViewProps {
templates?: TypesGen.Template[]
selectedTemplate?: TypesGen.Template
templateSchema?: TypesGen.ParameterSchema[]
workspaceQuota?: TypesGen.WorkspaceQuota
createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>>
canCreateForUser?: boolean
defaultWorkspaceOwner: TypesGen.User | null
owner: TypesGen.User | null
setOwner: (arg0: TypesGen.User | null) => void
onCancel: () => void
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
@@ -99,20 +102,21 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
<ErrorSummary
error={props.createWorkspaceErrors[CreateWorkspaceErrors.GET_TEMPLATES_ERROR]}
/>
) : (
<></>
)}
) : null}
{props.createWorkspaceErrors[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR] ? (
<ErrorSummary
error={props.createWorkspaceErrors[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]}
/>
) : (
<></>
)}
) : null}
</Stack>
)
}
const canSubmit =
props.workspaceQuota && props.workspaceQuota.user_workspace_limit > 0
? props.workspaceQuota.user_workspace_count < props.workspaceQuota.user_workspace_limit
: true
return (
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
<form onSubmit={form.handleSubmit}>
@@ -145,8 +149,8 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
{props.canCreateForUser && (
<UserAutocomplete
value={props.defaultWorkspaceOwner}
onChange={(user) => props.setOwner(user)}
value={props.owner}
onChange={props.setOwner}
label={t("ownerLabel")}
inputMargin="dense"
showAvatar
@@ -171,7 +175,20 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
</Stack>
)}
<FormFooter onCancel={props.onCancel} isLoading={props.creatingWorkspace} />
{props.workspaceQuota && (
<WorkspaceQuota
quota={props.workspaceQuota}
error={
props.createWorkspaceErrors[CreateWorkspaceErrors.GET_WORKSPACE_QUOTA_ERROR]
}
/>
)}
<FormFooter
onCancel={props.onCancel}
isLoading={props.creatingWorkspace}
submitDisabled={!canSubmit}
/>
</>
)}
</Stack>
+5
View File
@@ -850,3 +850,8 @@ export const MockAuditLog2: TypesGen.AuditLog = {
},
},
}
export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = {
user_workspace_count: 0,
user_workspace_limit: 100,
}
@@ -3,6 +3,7 @@ import {
createWorkspace,
getTemplates,
getTemplateVersionSchema,
getWorkspaceQuota,
} from "api/api"
import {
CreateWorkspaceRequest,
@@ -10,6 +11,7 @@ import {
Template,
User,
Workspace,
WorkspaceQuota,
} from "api/typesGenerated"
import { assign, createMachine } from "xstate"
@@ -17,6 +19,7 @@ type CreateWorkspaceContext = {
organizationId: string
owner: User | null
templateName: string
workspaceQuotaEnabled: boolean
templates?: Template[]
selectedTemplate?: Template
templateSchema?: ParameterSchema[]
@@ -27,6 +30,8 @@ type CreateWorkspaceContext = {
getTemplateSchemaError?: Error | unknown
permissions?: Record<string, boolean>
checkPermissionsError?: Error | unknown
workspaceQuota?: WorkspaceQuota
getWorkspaceQuotaError?: Error | unknown
}
type CreateWorkspaceEvent = {
@@ -35,6 +40,11 @@ type CreateWorkspaceEvent = {
owner: User | null
}
type SelectOwnerEvent = {
type: "SELECT_OWNER"
owner: User | null
}
export const createWorkspaceMachine = createMachine(
{
id: "createWorkspaceState",
@@ -42,7 +52,7 @@ export const createWorkspaceMachine = createMachine(
tsTypes: {} as import("./createWorkspaceXService.typegen").Typegen0,
schema: {
context: {} as CreateWorkspaceContext,
events: {} as CreateWorkspaceEvent,
events: {} as CreateWorkspaceEvent | SelectOwnerEvent,
services: {} as {
getTemplates: {
data: Template[]
@@ -50,6 +60,9 @@ export const createWorkspaceMachine = createMachine(
getTemplateSchema: {
data: ParameterSchema[]
}
getWorkspaceQuota: {
data: WorkspaceQuota
}
createWorkspace: {
data: Workspace
}
@@ -98,10 +111,23 @@ export const createWorkspaceMachine = createMachine(
id: "checkPermissions",
onDone: {
actions: ["assignPermissions"],
target: "fillingParams",
target: "gettingWorkspaceQuota",
},
onError: {
actions: ["assignCheckPermissionsError"],
},
},
},
gettingWorkspaceQuota: {
entry: "clearGetWorkspaceQuotaError",
invoke: {
src: "getWorkspaceQuota",
onDone: {
actions: ["assignWorkspaceQuota"],
target: "fillingParams",
},
onError: {
actions: ["assignGetWorkspaceQuotaError"],
target: "error",
},
},
@@ -112,6 +138,10 @@ export const createWorkspaceMachine = createMachine(
actions: ["assignCreateWorkspaceRequest", "assignOwner"],
target: "creatingWorkspace",
},
SELECT_OWNER: {
actions: ["assignOwner"],
target: "gettingWorkspaceQuota",
},
},
},
creatingWorkspace: {
@@ -178,6 +208,17 @@ export const createWorkspaceMachine = createMachine(
return createWorkspace(organizationId, owner?.id ?? "me", createWorkspaceRequest)
},
getWorkspaceQuota: (context) => {
if (!context.workspaceQuotaEnabled) {
// resolving with a limit of 0 will disable the component
return Promise.resolve({
user_workspace_count: 0,
user_workspace_limit: 0,
})
}
return getWorkspaceQuota(context.owner?.id ?? "me")
},
},
guards: {
areTemplatesEmpty: (_, event) => event.data.length === 0,
@@ -230,6 +271,15 @@ export const createWorkspaceMachine = createMachine(
clearGetTemplateSchemaError: assign({
getTemplateSchemaError: (_) => undefined,
}),
assignWorkspaceQuota: assign({
workspaceQuota: (_, event) => event.data,
}),
assignGetWorkspaceQuotaError: assign({
getWorkspaceQuotaError: (_, event) => event.data,
}),
clearGetWorkspaceQuotaError: assign({
getWorkspaceQuotaError: (_) => undefined,
}),
},
},
)