diff --git a/coderd/coderd.go b/coderd/coderd.go index 0ac4a7c68dc5f..6d638324710e4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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" diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 839633f7f815e..ca8c49f36eb8c 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 110c5d4410efe..56c5f677115d3 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 669a9d2e07d84..3f846e7bebf69 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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, diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index f2c1723f84ba4..8e9e0b60902eb 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -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 ( diff --git a/coderd/workspacequota/workspacequota.go b/coderd/workspacequota/workspacequota.go new file mode 100644 index 0000000000000..54bd46ca4165d --- /dev/null +++ b/coderd/workspacequota/workspacequota.go @@ -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 +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 3d08d222990aa..aa032a947d645 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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() diff --git a/codersdk/features.go b/codersdk/features.go index 89a3b7e9ed64f..0562d9e06c72e 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -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"` diff --git a/codersdk/workspacequota.go b/codersdk/workspacequota.go new file mode 100644 index 0000000000000..823e843e0e6e6 --- /dev/null +++ b/codersdk/workspacequota.go @@ -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("a) +} diff --git a/enterprise/cli/features_test.go b/enterprise/cli/features_test.go index 4832082e28c32..4621dd07e3def 100644 --- a/enterprise/cli/features_test.go +++ b/enterprise/cli/features_test.go @@ -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) }) } diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index f9961eba0cb5d..6dde1c31cd75c 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -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 } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b846447c55a43..f251f08d7f0d4 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -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) } diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index bef7fdb313f6f..912eb29090f4e 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -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) diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index a97e8ebcca4fe..9d43bbe6c2996 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -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 { diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index 0b74c1caf6239..c4b7111597079 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -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"]) }) } diff --git a/enterprise/coderd/workspacequota.go b/enterprise/coderd/workspacequota.go new file mode 100644 index 0000000000000..ab345d3681f87 --- /dev/null +++ b/enterprise/coderd/workspacequota.go @@ -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(), + }) +} diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go new file mode 100644 index 0000000000000..fb4f8f00b9dfd --- /dev/null +++ b/enterprise/coderd/workspacequota_test.go @@ -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) + }) +} diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 00ba5f6630efb..5aaec5bda4458 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -500,3 +500,8 @@ export const getApplicationsHost = async (): Promise => { + const response = await axios.get(`/api/v2/workspace-quota/${userID}`) + return response.data +} diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 63d01a0ab55dd..420f805ccd85d 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -20,4 +20,6 @@ export enum FeatureNames { AuditLog = "audit_log", UserLimit = "user_limit", BrowserOnly = "browser_only", + SCIM = "scim", + WorkspaceQuota = "workspace_quota", } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fdacbc6dacadf..caf536b8bcb48 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/components/FormFooter/FormFooter.tsx b/site/src/components/FormFooter/FormFooter.tsx index 916bcc9629d0c..598237a742bcc 100644 --- a/site/src/components/FormFooter/FormFooter.tsx +++ b/site/src/components/FormFooter/FormFooter.tsx @@ -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> = ({ onCancel, isLoading, submitLabel = Language.defaultSubmitLabel, + submitDisabled, }) => { const styles = useStyles() return ( @@ -45,6 +47,7 @@ export const FormFooter: FC> = ({ variant="contained" color="primary" type="submit" + disabled={submitDisabled} > {submitLabel} diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx new file mode 100644 index 0000000000000..9915db7a89093 --- /dev/null +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx @@ -0,0 +1,51 @@ +import { Story } from "@storybook/react" +import { WorkspaceQuota, WorkspaceQuotaProps } from "./WorkspaceQuota" + +export default { + title: "components/WorkspaceQuota", + component: WorkspaceQuota, +} + +const Template: Story = (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, + }, +} diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx new file mode 100644 index 0000000000000..e55ebf10c9f0d --- /dev/null +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -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 = ({ quota, error }) => { + const styles = useStyles() + + // error state + if (error !== undefined) { + return ( + + + Workspace Quota + + + + ) + } + + // loading + if (quota === undefined) { + return ( + + + Workspace Quota + +
+ +
+
+
+ ) + } + + // 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 ( + + + Workspace Quota + = quota.user_workspace_limit + ? styles.maxProgress + : undefined + } + value={value} + variant="determinate" + /> +
+ {quota.user_workspace_count} {Language.of} {quota.user_workspace_limit}{" "} + {quota.user_workspace_limit === 1 ? Language.workspace : Language.workspaces} + {" used"} +
+
+
+ ) +} + +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", + }, +})) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index c61ea82b64551..883b0cf0e1b7b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -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() diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 94ff2fabbc779..b863821525cae 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -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(me ?? null) - return ( <> @@ -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") }} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 1372f3517f46a..fbafa7bc03b09 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -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> 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 - ) : ( - <> - )} + ) : null} {props.createWorkspaceErrors[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR] ? ( - ) : ( - <> - )} + ) : null} ) } + const canSubmit = + props.workspaceQuota && props.workspaceQuota.user_workspace_limit > 0 + ? props.workspaceQuota.user_workspace_count < props.workspaceQuota.user_workspace_limit + : true + return (
@@ -145,8 +149,8 @@ export const CreateWorkspacePageView: FC props.setOwner(user)} + value={props.owner} + onChange={props.setOwner} label={t("ownerLabel")} inputMargin="dense" /> @@ -170,7 +174,20 @@ export const CreateWorkspacePageView: FC )} - + {props.workspaceQuota && ( + + )} + + )} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index fa889484de0ea..87c918baaacbc 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -850,3 +850,8 @@ export const MockAuditLog2: TypesGen.AuditLog = { }, }, } + +export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { + user_workspace_count: 0, + user_workspace_limit: 100, +} diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index d7f5a58a32f0e..c1176c55f09ec 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -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 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, + }), }, }, )