Implement Quotas v3 (#5012)

* provisioner/terraform: add cost to resource_metadata

* provisionerd/runner: use Options struct

* Complete provisionerd implementation

* Add quota_allowance to groups

* Combine Quota and RBAC licenses

* Add Opts to InTx
This commit is contained in:
Ammar Bandukwala
2022-11-14 17:57:33 +00:00
committed by GitHub
parent 3fb7892c07
commit 97dbd4dc5d
101 changed files with 1575 additions and 845 deletions
+2
View File
@@ -109,6 +109,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"name": ActionTrack,
"organization_id": ActionIgnore, // Never changes.
"avatar_url": ActionTrack,
"quota_allowance": ActionTrack,
},
// We don't show any diff for the WorkspaceBuild resource,
// save for the template_version_id
@@ -125,6 +126,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"job_id": ActionIgnore,
"deadline": ActionIgnore,
"reason": ActionIgnore,
"daily_cost": ActionIgnore,
},
})
-1
View File
@@ -61,7 +61,6 @@ func server() *cobra.Command {
AuditLogging: options.DeploymentConfig.AuditLogging.Value,
BrowserOnly: options.DeploymentConfig.BrowserOnly.Value,
SCIMAPIKey: []byte(options.DeploymentConfig.SCIMAPIKey.Value),
UserWorkspaceQuota: options.DeploymentConfig.UserWorkspaceQuota.Value,
RBAC: true,
DERPServerRelayAddress: options.DeploymentConfig.DERP.Server.RelayURL.Value,
DERPServerRegionID: options.DeploymentConfig.DERP.Server.RegionID.Value,
+12 -10
View File
@@ -20,12 +20,12 @@ import (
"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"
"github.com/coder/coder/enterprise/coderd/license"
"github.com/coder/coder/enterprise/derpmesh"
"github.com/coder/coder/enterprise/replicasync"
"github.com/coder/coder/enterprise/tailnet"
"github.com/coder/coder/provisionerd/proto"
agpltailnet "github.com/coder/coder/tailnet"
)
@@ -113,7 +113,9 @@ func New(ctx context.Context, options *Options) (*API, error) {
})
r.Route("/workspace-quota", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Use(
apiKeyMiddleware,
)
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database, false))
r.Get("/", api.workspaceQuota)
@@ -183,9 +185,8 @@ type Options struct {
RBAC bool
AuditLogging bool
// Whether to block non-browser connections.
BrowserOnly bool
SCIMAPIKey []byte
UserWorkspaceQuota int
BrowserOnly bool
SCIMAPIKey []byte
// Used for high availability.
DERPServerRelayAddress string
@@ -224,7 +225,6 @@ func (api *API) updateEntitlements(ctx context.Context) error {
codersdk.FeatureAuditLog: api.AuditLogging,
codersdk.FeatureBrowserOnly: api.BrowserOnly,
codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0,
codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0,
codersdk.FeatureHighAvailability: api.DERPServerRelayAddress != "",
codersdk.FeatureMultipleGitAuth: len(api.GitAuthConfigs) > 1,
codersdk.FeatureTemplateRBAC: api.RBAC,
@@ -262,12 +262,14 @@ func (api *API) updateEntitlements(ctx context.Context) error {
api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler)
}
if changed, enabled := featureChanged(codersdk.FeatureWorkspaceQuota); changed {
enforcer := workspacequota.NewNop()
if changed, enabled := featureChanged(codersdk.FeatureTemplateRBAC); changed {
if enabled {
enforcer = NewEnforcer(api.Options.UserWorkspaceQuota)
committer := committer{Database: api.Database}
ptr := proto.QuotaCommitter(&committer)
api.AGPL.QuotaCommitter.Store(&ptr)
} else {
api.AGPL.QuotaCommitter.Store(nil)
}
api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer)
}
if changed, enabled := featureChanged(codersdk.FeatureHighAvailability); changed {
@@ -70,7 +70,6 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
SCIMAPIKey: options.SCIMAPIKey,
DERPServerRelayAddress: oop.AccessURL.String(),
DERPServerRegionID: oop.DERPMap.RegionIDs()[0],
UserWorkspaceQuota: options.UserWorkspaceQuota,
Options: oop,
EntitlementsUpdateInterval: options.EntitlementsUpdateInterval,
Keys: Keys,
@@ -110,7 +109,6 @@ type LicenseOptions struct {
AuditLog bool
BrowserOnly bool
SCIM bool
WorkspaceQuota bool
TemplateRBAC bool
HighAvailability bool
MultipleGitAuth bool
@@ -145,10 +143,6 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
if options.SCIM {
scim = 1
}
var workspaceQuota int64
if options.WorkspaceQuota {
workspaceQuota = 1
}
highAvailability := int64(0)
if options.HighAvailability {
highAvailability = 1
@@ -182,7 +176,6 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
AuditLog: auditLog,
BrowserOnly: browserOnly,
SCIM: scim,
WorkspaceQuota: workspaceQuota,
HighAvailability: highAvailability,
TemplateRBAC: rbacEnabled,
MultipleGitAuth: multipleGitAuth,
+20 -12
View File
@@ -53,6 +53,7 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request)
Name: req.Name,
OrganizationID: org.ID,
AvatarURL: req.AvatarURL,
QuotaAllowance: int32(req.QuotaAllowance),
})
if database.IsUniqueViolation(err) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
@@ -155,19 +156,25 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) {
return xerrors.Errorf("get group by ID: %w", err)
}
// TODO: Do we care about validating this?
if req.AvatarURL != nil {
group.AvatarURL = *req.AvatarURL
}
if req.Name != "" {
group.Name = req.Name
updateGroupParams := database.UpdateGroupByIDParams{
ID: group.ID,
AvatarURL: group.AvatarURL,
Name: group.Name,
QuotaAllowance: group.QuotaAllowance,
}
group, err = tx.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{
ID: group.ID,
Name: group.Name,
AvatarURL: group.AvatarURL,
})
// TODO: Do we care about validating this?
if req.AvatarURL != nil {
updateGroupParams.AvatarURL = *req.AvatarURL
}
if req.Name != "" {
updateGroupParams.Name = req.Name
}
if req.QuotaAllowance != nil {
updateGroupParams.QuotaAllowance = int32(*req.QuotaAllowance)
}
group, err = tx.UpdateGroupByID(ctx, updateGroupParams)
if err != nil {
return xerrors.Errorf("update group by ID: %w", err)
}
@@ -188,7 +195,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) {
}
}
return nil
})
}, nil)
if database.IsUniqueViolation(err) {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: "Cannot add the same user to a group twice!",
@@ -327,6 +334,7 @@ func convertGroup(g database.Group, users []database.User) codersdk.Group {
Name: g.Name,
OrganizationID: g.OrganizationID,
AvatarURL: g.AvatarURL,
QuotaAllowance: int(g.QuotaAllowance),
Members: convertUsers(users, orgs),
}
}
+8 -4
View File
@@ -129,18 +129,22 @@ func TestPatchGroup(t *testing.T) {
})
ctx, _ := testutil.Context(t)
group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
Name: "hi",
AvatarURL: "https://example.com",
Name: "hi",
AvatarURL: "https://example.com",
QuotaAllowance: 10,
})
require.NoError(t, err)
require.Equal(t, 10, group.QuotaAllowance)
group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
Name: "bye",
AvatarURL: pointer.String("https://google.com"),
Name: "bye",
AvatarURL: pointer.String("https://google.com"),
QuotaAllowance: pointer.Int(20),
})
require.NoError(t, err)
require.Equal(t, "bye", group.Name)
require.Equal(t, "https://google.com", group.AvatarURL)
require.Equal(t, 20, group.QuotaAllowance)
})
// The FE sends a request from the edit page where the old name == new name.
-7
View File
@@ -99,12 +99,6 @@ func Entitlements(
Enabled: enablements[codersdk.FeatureSCIM],
}
}
if claims.Features.WorkspaceQuota > 0 {
entitlements.Features[codersdk.FeatureWorkspaceQuota] = codersdk.Feature{
Entitlement: entitlement,
Enabled: enablements[codersdk.FeatureWorkspaceQuota],
}
}
if claims.Features.HighAvailability > 0 {
entitlements.Features[codersdk.FeatureHighAvailability] = codersdk.Feature{
Entitlement: entitlement,
@@ -248,7 +242,6 @@ type Features struct {
AuditLog int64 `json:"audit_log"`
BrowserOnly int64 `json:"browser_only"`
SCIM int64 `json:"scim"`
WorkspaceQuota int64 `json:"workspace_quota"`
TemplateRBAC int64 `json:"template_rbac"`
HighAvailability int64 `json:"high_availability"`
MultipleGitAuth int64 `json:"multiple_git_auth"`
@@ -23,7 +23,6 @@ func TestEntitlements(t *testing.T) {
codersdk.FeatureAuditLog: true,
codersdk.FeatureBrowserOnly: true,
codersdk.FeatureSCIM: true,
codersdk.FeatureWorkspaceQuota: true,
codersdk.FeatureHighAvailability: true,
codersdk.FeatureTemplateRBAC: true,
codersdk.FeatureMultipleGitAuth: true,
@@ -66,7 +65,6 @@ func TestEntitlements(t *testing.T) {
AuditLog: true,
BrowserOnly: true,
SCIM: true,
WorkspaceQuota: true,
HighAvailability: true,
TemplateRBAC: true,
MultipleGitAuth: true,
@@ -90,7 +88,6 @@ func TestEntitlements(t *testing.T) {
AuditLog: true,
BrowserOnly: true,
SCIM: true,
WorkspaceQuota: true,
HighAvailability: true,
TemplateRBAC: true,
GraceAt: time.Now().Add(-time.Hour),
-2
View File
@@ -105,7 +105,6 @@ func TestGetLicense(t *testing.T) {
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureBrowserOnly: json.Number("1"),
codersdk.FeatureWorkspaceQuota: json.Number("0"),
codersdk.FeatureHighAvailability: json.Number("0"),
codersdk.FeatureTemplateRBAC: json.Number("1"),
codersdk.FeatureMultipleGitAuth: json.Number("0"),
@@ -118,7 +117,6 @@ func TestGetLicense(t *testing.T) {
codersdk.FeatureAuditLog: json.Number("1"),
codersdk.FeatureSCIM: json.Number("1"),
codersdk.FeatureBrowserOnly: json.Number("1"),
codersdk.FeatureWorkspaceQuota: json.Number("0"),
codersdk.FeatureHighAvailability: json.Number("0"),
codersdk.FeatureTemplateRBAC: json.Number("0"),
codersdk.FeatureMultipleGitAuth: json.Number("0"),
+1 -1
View File
@@ -171,7 +171,7 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) {
return xerrors.Errorf("update template ACL by ID: %w", err)
}
return nil
})
}, nil)
if err != nil {
httpapi.InternalServerError(rw, err)
return
+105 -24
View File
@@ -1,36 +1,102 @@
package coderd
import (
"context"
"database/sql"
"net/http"
"github.com/google/uuid"
"golang.org/x/xerrors"
"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"
"github.com/coder/coder/provisionerd/proto"
)
type enforcer struct {
userWorkspaceLimit int
type committer struct {
Database database.Store
}
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
func (c *committer) CommitQuota(
ctx context.Context, request *proto.CommitQuotaRequest,
) (*proto.CommitQuotaResponse, error) {
jobID, err := uuid.Parse(request.JobId)
if err != nil {
return nil, err
}
return count < e.userWorkspaceLimit
build, err := c.Database.GetWorkspaceBuildByJobID(ctx, jobID)
if err != nil {
return nil, err
}
workspace, err := c.Database.GetWorkspaceByID(ctx, build.WorkspaceID)
if err != nil {
return nil, err
}
var (
consumed int64
budget int64
permit bool
)
err = c.Database.InTx(func(s database.Store) error {
var err error
consumed, err = s.GetQuotaConsumedForUser(ctx, workspace.OwnerID)
if err != nil {
return err
}
budget, err = s.GetQuotaAllowanceForUser(ctx, workspace.OwnerID)
if err != nil {
return err
}
// If the new build will reduce overall quota consumption, then we
// allow it even if the user is over quota.
netIncrease := true
previousBuild, err := s.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{
WorkspaceID: workspace.ID,
BuildNumber: build.BuildNumber - 1,
})
if err == nil {
if build.DailyCost < previousBuild.DailyCost {
netIncrease = false
}
} else if !xerrors.Is(err, sql.ErrNoRows) {
return err
}
newConsumed := int64(request.DailyCost) + consumed
if newConsumed > budget && netIncrease {
return nil
}
_, err = s.UpdateWorkspaceBuildCostByID(ctx, database.UpdateWorkspaceBuildCostByIDParams{
ID: build.ID,
DailyCost: request.DailyCost,
})
if err != nil {
return err
}
permit = true
consumed = newConsumed
return nil
}, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return nil, err
}
return &proto.CommitQuotaResponse{
Ok: permit,
CreditsConsumed: int32(consumed),
Budget: int32(budget),
}, nil
}
func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
@@ -41,20 +107,35 @@ func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) {
return
}
workspaces, err := api.Database.GetWorkspaces(r.Context(), database.GetWorkspacesParams{
OwnerID: user.ID,
})
api.entitlementsMu.RLock()
licensed := api.entitlements.Features[codersdk.FeatureTemplateRBAC].Enabled
api.entitlementsMu.RUnlock()
// There are no groups and thus no allowance if RBAC isn't licensed.
var quotaAllowance int64 = -1
if licensed {
var err error
quotaAllowance, err = api.Database.GetQuotaAllowanceForUser(r.Context(), user.ID)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get allowance",
Detail: err.Error(),
})
return
}
}
quotaConsumed, err := api.Database.GetQuotaConsumedForUser(r.Context(), user.ID)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspaces.",
Message: "Failed to get consumed",
Detail: err.Error(),
})
return
}
e := *api.AGPL.WorkspaceQuotaEnforcer.Load()
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceQuota{
UserWorkspaceCount: len(workspaces),
UserWorkspaceLimit: e.UserWorkspaceLimit(),
CreditsConsumed: int(quotaConsumed),
Budget: int(quotaAllowance),
})
}
+82 -61
View File
@@ -3,13 +3,11 @@ 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"
@@ -17,49 +15,22 @@ import (
"github.com/coder/coder/testutil"
)
func verifyQuota(ctx context.Context, t *testing.T, client *codersdk.Client, consumed, total int) {
t.Helper()
got, err := client.WorkspaceQuota(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, codersdk.WorkspaceQuota{
Budget: total,
CreditsConsumed: consumed,
}, got)
}
func TestWorkspaceQuota(t *testing.T) {
// TODO: refactor for new impl
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)
@@ -71,14 +42,38 @@ func TestWorkspaceQuota(t *testing.T) {
IncludeProvisionerDaemon: true,
},
})
user := coderdtest.CreateFirstUser(t, client)
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
WorkspaceQuota: true,
TemplateRBAC: true,
})
verifyQuota(ctx, t, client, 0, 0)
// Add user to two groups, granting them a total budget of 3.
group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
Name: "test-1",
QuotaAllowance: 1,
})
q1, err := client.WorkspaceQuota(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, q1.UserWorkspaceCount, 0)
require.EqualValues(t, q1.UserWorkspaceLimit, max)
group2, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
Name: "test-2",
QuotaAllowance: 2,
})
require.NoError(t, err)
_, err = client.PatchGroup(ctx, group1.ID, codersdk.PatchGroupRequest{
AddUsers: []string{user.UserID.String()},
})
require.NoError(t, err)
_, err = client.PatchGroup(ctx, group2.ID, codersdk.PatchGroupRequest{
AddUsers: []string{user.UserID.String()},
})
require.NoError(t, err)
verifyQuota(ctx, t, client, 0, 3)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
@@ -87,8 +82,9 @@ func TestWorkspaceQuota(t *testing.T) {
Type: &proto.Provision_Response_Complete{
Complete: &proto.Provision_Complete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Name: "example",
Type: "aws_instance",
DailyCost: 1,
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "example",
@@ -103,20 +99,45 @@ func TestWorkspaceQuota(t *testing.T) {
})
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)
// Spin up three workspaces fine
for i := 0; i < 3; i++ {
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
verifyQuota(ctx, t, client, i+1, 3)
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
}
// Next one must fail
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
// Consumed shouldn't bump
verifyQuota(ctx, t, client, 3, 3)
require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status)
require.Contains(t, build.Job.Error, "quota")
// Delete one random workspace, then quota should recover.
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{})
require.NoError(t, err)
require.EqualValues(t, q1.UserWorkspaceCount, 1)
require.EqualValues(t, q1.UserWorkspaceLimit, max)
for _, w := range workspaces.Workspaces {
if w.LatestBuild.Status != codersdk.WorkspaceStatusRunning {
continue
}
build, err := client.CreateWorkspaceBuild(ctx, w.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionDelete,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID)
verifyQuota(ctx, t, client, 2, 3)
break
}
// Next one should now succeed
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
build = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
verifyQuota(ctx, t, client, 3, 3)
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
})
}