mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: workspace quotas (#4184)
This commit is contained in:
+10
-2
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -20,4 +20,6 @@ export enum FeatureNames {
|
||||
AuditLog = "audit_log",
|
||||
UserLimit = "user_limit",
|
||||
BrowserOnly = "browser_only",
|
||||
SCIM = "scim",
|
||||
WorkspaceQuota = "workspace_quota",
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user