chore: revert feat(enterprise/coderd): allow system users to be added to groups (#19254)

This reverts commit b200fc8e67
(https://github.com/coder/coder/pull/18341).
This commit is contained in:
Cian Johnston
2025-08-08 12:18:07 +01:00
committed by GitHub
parent b200fc8e67
commit afb54f6884
8 changed files with 99 additions and 520 deletions
+21 -68
View File
@@ -12,11 +12,6 @@ import (
"github.com/coder/quartz"
)
const (
PrebuiltWorkspacesGroupName = "coder_prebuilt_workspaces"
PrebuiltWorkspacesGroupDisplayName = "Prebuilt Workspaces"
)
// StoreMembershipReconciler encapsulates the responsibility of ensuring that the prebuilds system user is a member of all
// organizations for which prebuilt workspaces are requested. This is necessary because our data model requires that such
// prebuilt workspaces belong to a member of the organization of their eventual claimant.
@@ -32,16 +27,11 @@ func NewStoreMembershipReconciler(store database.Store, clock quartz.Clock) Stor
}
}
// ReconcileAll compares the current organization and group memberships of a user to the memberships required
// in order to create prebuilt workspaces. If the user in question is not yet a member of an organization that
// needs prebuilt workspaces, ReconcileAll will create the membership required.
// ReconcileAll compares the current membership of a user to the membership required in order to create prebuilt workspaces.
// If the user in question is not yet a member of an organization that needs prebuilt workspaces, ReconcileAll will create
// the membership required.
//
// To facilitate quota management, ReconcileAll will ensure:
// * the existence of a group (defined by PrebuiltWorkspacesGroupName) in each organization that needs prebuilt workspaces
// * that the prebuilds system user belongs to the group in each organization that needs prebuilt workspaces
// * that the group has a quota of 0 by default, which users can adjust based on their needs.
//
// ReconcileAll does not have an opinion on transaction or lock management. These responsibilities are left to the caller.
// This method does not have an opinion on transaction or lock management. These responsibilities are left to the caller.
func (s StoreMembershipReconciler) ReconcileAll(ctx context.Context, userID uuid.UUID, presets []database.GetTemplatePresetsWithPrebuildsRow) error {
organizationMemberships, err := s.store.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
UserID: userID,
@@ -54,74 +44,37 @@ func (s StoreMembershipReconciler) ReconcileAll(ctx context.Context, userID uuid
return xerrors.Errorf("determine prebuild organization membership: %w", err)
}
orgMemberships := make(map[uuid.UUID]struct{}, 0)
systemUserMemberships := make(map[uuid.UUID]struct{}, 0)
defaultOrg, err := s.store.GetDefaultOrganization(ctx)
if err != nil {
return xerrors.Errorf("get default organization: %w", err)
}
orgMemberships[defaultOrg.ID] = struct{}{}
systemUserMemberships[defaultOrg.ID] = struct{}{}
for _, o := range organizationMemberships {
orgMemberships[o.ID] = struct{}{}
systemUserMemberships[o.ID] = struct{}{}
}
var membershipInsertionErrors error
for _, preset := range presets {
_, alreadyOrgMember := orgMemberships[preset.OrganizationID]
if !alreadyOrgMember {
// Add the organization to our list of memberships regardless of potential failure below
// to avoid a retry that will probably be doomed anyway.
orgMemberships[preset.OrganizationID] = struct{}{}
// Insert the missing membership
_, err = s.store.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{
OrganizationID: preset.OrganizationID,
UserID: userID,
CreatedAt: s.clock.Now(),
UpdatedAt: s.clock.Now(),
Roles: []string{},
})
if err != nil {
membershipInsertionErrors = errors.Join(membershipInsertionErrors, xerrors.Errorf("insert membership for prebuilt workspaces: %w", err))
continue
}
_, alreadyMember := systemUserMemberships[preset.OrganizationID]
if alreadyMember {
continue
}
// Add the organization to our list of memberships regardless of potential failure below
// to avoid a retry that will probably be doomed anyway.
systemUserMemberships[preset.OrganizationID] = struct{}{}
// Create a "prebuilds" group in the organization and add the system user to it
// This group will have a quota of 0 by default, which users can adjust based on their needs
prebuildsGroup, err := s.store.InsertGroup(ctx, database.InsertGroupParams{
ID: uuid.New(),
Name: PrebuiltWorkspacesGroupName,
DisplayName: PrebuiltWorkspacesGroupDisplayName,
// Insert the missing membership
_, err = s.store.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{
OrganizationID: preset.OrganizationID,
AvatarURL: "",
QuotaAllowance: 0, // Default quota of 0, users should set this based on their needs
UserID: userID,
CreatedAt: s.clock.Now(),
UpdatedAt: s.clock.Now(),
Roles: []string{},
})
if err != nil {
// If the group already exists, try to get it
if !database.IsUniqueViolation(err) {
membershipInsertionErrors = errors.Join(membershipInsertionErrors, xerrors.Errorf("create prebuilds group: %w", err))
continue
}
prebuildsGroup, err = s.store.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{
OrganizationID: preset.OrganizationID,
Name: PrebuiltWorkspacesGroupName,
})
if err != nil {
membershipInsertionErrors = errors.Join(membershipInsertionErrors, xerrors.Errorf("get existing prebuilds group: %w", err))
continue
}
}
// Add the system user to the prebuilds group
err = s.store.InsertGroupMember(ctx, database.InsertGroupMemberParams{
GroupID: prebuildsGroup.ID,
UserID: userID,
})
if err != nil {
// Ignore unique violation errors as the user might already be in the group
if !database.IsUniqueViolation(err) {
membershipInsertionErrors = errors.Join(membershipInsertionErrors, xerrors.Errorf("add system user to prebuilds group: %w", err))
}
membershipInsertionErrors = errors.Join(membershipInsertionErrors, xerrors.Errorf("insert membership for prebuilt workspaces: %w", err))
continue
}
}
return membershipInsertionErrors
+74 -162
View File
@@ -1,23 +1,18 @@
package prebuilds_test
import (
"database/sql"
"errors"
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"tailscale.com/types/ptr"
"github.com/coder/quartz"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/enterprise/coderd/prebuilds"
"github.com/coder/coder/v2/testutil"
)
// TestReconcileAll verifies that StoreMembershipReconciler correctly updates membership
@@ -25,6 +20,7 @@ import (
func TestReconcileAll(t *testing.T) {
t.Parallel()
ctx := context.Background()
clock := quartz.NewMock(t)
// Helper to build a minimal Preset row belonging to a given org.
@@ -36,171 +32,87 @@ func TestReconcileAll(t *testing.T) {
}
tests := []struct {
name string
includePreset []bool
preExistingOrgMembership []bool
preExistingGroup []bool
preExistingGroupMembership []bool
// Expected outcomes
expectOrgMembershipExists *bool
expectGroupExists *bool
expectUserInGroup *bool
name string
includePreset bool
preExistingMembership bool
}{
{
name: "if there are no presets, membership reconciliation is a no-op",
includePreset: []bool{false},
preExistingOrgMembership: []bool{true, false},
preExistingGroup: []bool{true, false},
preExistingGroupMembership: []bool{true, false},
expectOrgMembershipExists: ptr.To(false),
expectGroupExists: ptr.To(false),
},
{
name: "if there is a preset, then we should enforce org and group membership in all cases",
includePreset: []bool{true},
preExistingOrgMembership: []bool{true, false},
preExistingGroup: []bool{true, false},
preExistingGroupMembership: []bool{true, false},
expectOrgMembershipExists: ptr.To(true),
expectGroupExists: ptr.To(true),
expectUserInGroup: ptr.To(true),
},
// The StoreMembershipReconciler acts based on the provided agplprebuilds.GlobalSnapshot.
// These test cases must therefore trust any valid snapshot, so the only relevant functional test cases are:
// No presets to act on and the prebuilds user does not belong to any organizations.
// Reconciliation should be a no-op
{name: "no presets, no memberships", includePreset: false, preExistingMembership: false},
// If we have a preset that requires prebuilds, but the prebuilds user is not a member of
// that organization, then we should add the membership.
{name: "preset, but no membership", includePreset: true, preExistingMembership: false},
// If the prebuilds system user is already a member of the organization to which a preset belongs,
// then reconciliation should be a no-op:
{name: "preset, but already a member", includePreset: true, preExistingMembership: true},
// If the prebuilds system user is a member of an organization that doesn't have need any prebuilds,
// then it must have required prebuilds in the past. The membership is not currently necessary, but
// the reconciler won't remove it, because there's little cost to keeping it and prebuilds might be
// enabled again.
{name: "member, but no presets", includePreset: false, preExistingMembership: true},
}
for _, tc := range tests {
tc := tc
for _, includePreset := range tc.includePreset {
includePreset := includePreset
for _, preExistingOrgMembership := range tc.preExistingOrgMembership {
preExistingOrgMembership := preExistingOrgMembership
for _, preExistingGroup := range tc.preExistingGroup {
preExistingGroup := preExistingGroup
for _, preExistingGroupMembership := range tc.preExistingGroupMembership {
preExistingGroupMembership := preExistingGroupMembership
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// nolint:gocritic // Reconciliation happens as prebuilds system user, not a human user.
ctx := dbauthz.AsPrebuildsOrchestrator(testutil.Context(t, testutil.WaitLong))
_, db := coderdtest.NewWithDatabase(t, nil)
db, _ := dbtestutil.NewDB(t)
defaultOrg, err := db.GetDefaultOrganization(ctx)
require.NoError(t, err)
defaultOrg, err := db.GetDefaultOrganization(ctx)
require.NoError(t, err)
// introduce an unrelated organization to ensure that the membership reconciler don't interfere with it.
unrelatedOrg := dbgen.Organization(t, db, database.Organization{})
targetOrg := dbgen.Organization(t, db, database.Organization{})
// introduce an unrelated organization to ensure that the membership reconciler don't interfere with it.
unrelatedOrg := dbgen.Organization(t, db, database.Organization{})
targetOrg := dbgen.Organization(t, db, database.Organization{})
if !dbtestutil.WillUsePostgres() {
// dbmem doesn't ensure membership to the default organization
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: defaultOrg.ID,
UserID: database.PrebuildsSystemUserID,
})
}
// Ensure membership to unrelated org.
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: unrelatedOrg.ID, UserID: database.PrebuildsSystemUserID})
if preExistingOrgMembership {
// System user already a member of both orgs.
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: targetOrg.ID, UserID: database.PrebuildsSystemUserID})
}
// Create pre-existing prebuilds group if required by test case
var prebuildsGroup database.Group
if preExistingGroup {
prebuildsGroup = dbgen.Group(t, db, database.Group{
Name: prebuilds.PrebuiltWorkspacesGroupName,
DisplayName: prebuilds.PrebuiltWorkspacesGroupDisplayName,
OrganizationID: targetOrg.ID,
QuotaAllowance: 0,
})
// Add the system user to the group if preExistingGroupMembership is true
if preExistingGroupMembership {
dbgen.GroupMember(t, db, database.GroupMemberTable{
GroupID: prebuildsGroup.ID,
UserID: database.PrebuildsSystemUserID,
})
}
}
presets := []database.GetTemplatePresetsWithPrebuildsRow{newPresetRow(unrelatedOrg.ID)}
if includePreset {
presets = append(presets, newPresetRow(targetOrg.ID))
}
// Verify memberships before reconciliation.
preReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
UserID: database.PrebuildsSystemUserID,
})
require.NoError(t, err)
expectedMembershipsBefore := []uuid.UUID{defaultOrg.ID, unrelatedOrg.ID}
if preExistingOrgMembership {
expectedMembershipsBefore = append(expectedMembershipsBefore, targetOrg.ID)
}
require.ElementsMatch(t, expectedMembershipsBefore, extractOrgIDs(preReconcileMemberships))
// Reconcile
reconciler := prebuilds.NewStoreMembershipReconciler(db, clock)
require.NoError(t, reconciler.ReconcileAll(ctx, database.PrebuildsSystemUserID, presets))
// Verify memberships after reconciliation.
postReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
UserID: database.PrebuildsSystemUserID,
})
require.NoError(t, err)
expectedMembershipsAfter := expectedMembershipsBefore
if !preExistingOrgMembership && tc.expectOrgMembershipExists != nil && *tc.expectOrgMembershipExists {
expectedMembershipsAfter = append(expectedMembershipsAfter, targetOrg.ID)
}
require.ElementsMatch(t, expectedMembershipsAfter, extractOrgIDs(postReconcileMemberships))
// Verify prebuilds group behavior based on expected outcomes
prebuildsGroup, err = db.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{
OrganizationID: targetOrg.ID,
Name: prebuilds.PrebuiltWorkspacesGroupName,
})
if tc.expectGroupExists != nil && *tc.expectGroupExists {
require.NoError(t, err)
require.Equal(t, prebuilds.PrebuiltWorkspacesGroupName, prebuildsGroup.Name)
require.Equal(t, prebuilds.PrebuiltWorkspacesGroupDisplayName, prebuildsGroup.DisplayName)
require.Equal(t, int32(0), prebuildsGroup.QuotaAllowance) // Default quota should be 0
if tc.expectUserInGroup != nil && *tc.expectUserInGroup {
// Check that the system user is a member of the prebuilds group
groupMembers, err := db.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: prebuildsGroup.ID,
IncludeSystem: true,
})
require.NoError(t, err)
require.Len(t, groupMembers, 1)
require.Equal(t, database.PrebuildsSystemUserID, groupMembers[0].UserID)
}
// If no preset exists, then we do not enforce group membership:
if tc.expectUserInGroup != nil && !*tc.expectUserInGroup {
// Check that the system user is NOT a member of the prebuilds group
groupMembers, err := db.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: prebuildsGroup.ID,
IncludeSystem: true,
})
require.NoError(t, err)
require.Len(t, groupMembers, 0)
}
}
if !preExistingGroup && tc.expectGroupExists != nil && !*tc.expectGroupExists {
// Verify that no prebuilds group exists
require.Error(t, err)
require.True(t, errors.Is(err, sql.ErrNoRows))
}
})
}
}
if !dbtestutil.WillUsePostgres() {
// dbmem doesn't ensure membership to the default organization
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: defaultOrg.ID,
UserID: database.PrebuildsSystemUserID,
})
}
}
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: unrelatedOrg.ID, UserID: database.PrebuildsSystemUserID})
if tc.preExistingMembership {
// System user already a member of both orgs.
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: targetOrg.ID, UserID: database.PrebuildsSystemUserID})
}
presets := []database.GetTemplatePresetsWithPrebuildsRow{newPresetRow(unrelatedOrg.ID)}
if tc.includePreset {
presets = append(presets, newPresetRow(targetOrg.ID))
}
// Verify memberships before reconciliation.
preReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
UserID: database.PrebuildsSystemUserID,
})
require.NoError(t, err)
expectedMembershipsBefore := []uuid.UUID{defaultOrg.ID, unrelatedOrg.ID}
if tc.preExistingMembership {
expectedMembershipsBefore = append(expectedMembershipsBefore, targetOrg.ID)
}
require.ElementsMatch(t, expectedMembershipsBefore, extractOrgIDs(preReconcileMemberships))
// Reconcile
reconciler := prebuilds.NewStoreMembershipReconciler(db, clock)
require.NoError(t, reconciler.ReconcileAll(ctx, database.PrebuildsSystemUserID, presets))
// Verify memberships after reconciliation.
postReconcileMemberships, err := db.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
UserID: database.PrebuildsSystemUserID,
})
require.NoError(t, err)
expectedMembershipsAfter := expectedMembershipsBefore
if !tc.preExistingMembership && tc.includePreset {
expectedMembershipsAfter = append(expectedMembershipsAfter, targetOrg.ID)
}
require.ElementsMatch(t, expectedMembershipsAfter, extractOrgIDs(postReconcileMemberships))
})
}
}
-259
View File
@@ -395,265 +395,6 @@ func TestWorkspaceQuota(t *testing.T) {
verifyQuotaUser(ctx, t, client, second.Org.ID.String(), user.ID.String(), consumed, 35)
})
// ZeroQuota tests that a user with a zero quota allowance can't create a workspace.
// Although relevant for all users, this test ensures that the prebuilds system user
// cannot create workspaces in an organization for which it has exhausted its quota.
t.Run("ZeroQuota", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Create a client with no quota allowance
client, _, api, user := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
UserWorkspaceQuota: 0, // Set user workspace quota to 0
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
coderdtest.NewProvisionerDaemon(t, api.AGPL)
// Verify initial quota is 0
verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 0)
// Create a template with a workspace that costs 1 credit
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
DailyCost: 1,
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "example",
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
// Attempt to create a workspace with zero quota - should fail
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Verify the build failed due to quota
require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status)
require.Contains(t, build.Job.Error, "quota")
// Verify quota consumption remains at 0
verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 0)
// Test with a template that has zero cost - should pass
versionZeroCost := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
DailyCost: 0, // Zero cost workspace
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "example",
Auth: &proto.Agent_Token{
Token: uuid.NewString(),
},
}},
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, versionZeroCost.ID)
templateZeroCost := coderdtest.CreateTemplate(t, client, user.OrganizationID, versionZeroCost.ID)
// Workspace with zero cost should pass
workspaceZeroCost := coderdtest.CreateWorkspace(t, client, templateZeroCost.ID)
buildZeroCost := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceZeroCost.LatestBuild.ID)
require.Equal(t, codersdk.WorkspaceStatusRunning, buildZeroCost.Status)
require.Empty(t, buildZeroCost.Job.Error)
// Verify quota consumption remains at 0
verifyQuota(ctx, t, client, user.OrganizationID.String(), 0, 0)
})
// MultiOrg tests that a user can create workspaces in multiple organizations
// as long as they have enough quota in each organization. Specifically,
// in exhausted quota in one organization does not affect the ability to
// create workspaces in other organizations. This test is relevant to all users
// but is particularly relevant for the prebuilds system user.
t.Run("MultiOrg", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Create a setup with multiple organizations
owner, _, api, first := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureMultipleOrganizations: 1,
codersdk.FeatureExternalProvisionerDaemons: 1,
},
},
})
coderdtest.NewProvisionerDaemon(t, api.AGPL)
// Create a second organization
second := coderdenttest.CreateOrganization(t, owner, coderdenttest.CreateOrganizationOptions{
IncludeProvisionerDaemon: true,
})
// Create a user that will be a member of both organizations
user, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgMember(second.ID))
// Set up quota allowances for both organizations
// First org: 2 credits total
_, err := owner.PatchGroup(ctx, first.OrganizationID, codersdk.PatchGroupRequest{
QuotaAllowance: ptr.Ref(2),
})
require.NoError(t, err)
// Second org: 3 credits total
_, err = owner.PatchGroup(ctx, second.ID, codersdk.PatchGroupRequest{
QuotaAllowance: ptr.Ref(3),
})
require.NoError(t, err)
// Verify initial quotas
verifyQuota(ctx, t, user, first.OrganizationID.String(), 0, 2)
verifyQuota(ctx, t, user, second.ID.String(), 0, 3)
// Create templates for both organizations
authToken := uuid.NewString()
version1 := coderdtest.CreateTemplateVersion(t, owner, first.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
DailyCost: 1,
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "example",
Auth: &proto.Agent_Token{
Token: authToken,
},
}},
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, owner, version1.ID)
template1 := coderdtest.CreateTemplate(t, owner, first.OrganizationID, version1.ID)
version2 := coderdtest.CreateTemplateVersion(t, owner, second.ID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
DailyCost: 1,
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "example",
Auth: &proto.Agent_Token{
Token: uuid.NewString(),
},
}},
}},
},
},
}},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, owner, version2.ID)
template2 := coderdtest.CreateTemplate(t, owner, second.ID, version2.ID)
// Exhaust quota in the first organization by creating 2 workspaces
var workspaces1 []codersdk.Workspace
for i := 0; i < 2; i++ {
workspace := coderdtest.CreateWorkspace(t, user, template1.ID)
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, workspace.LatestBuild.ID)
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
workspaces1 = append(workspaces1, workspace)
}
// Verify first org quota is exhausted
verifyQuota(ctx, t, user, first.OrganizationID.String(), 2, 2)
// Try to create another workspace in the first org - should fail
workspace := coderdtest.CreateWorkspace(t, user, template1.ID)
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, workspace.LatestBuild.ID)
require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status)
require.Contains(t, build.Job.Error, "quota")
// Verify first org quota consumption didn't increase
verifyQuota(ctx, t, user, first.OrganizationID.String(), 2, 2)
// Verify second org quota is still available
verifyQuota(ctx, t, user, second.ID.String(), 0, 3)
// Create workspaces in the second organization - should succeed
for i := 0; i < 3; i++ {
workspace := coderdtest.CreateWorkspace(t, user, template2.ID)
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, workspace.LatestBuild.ID)
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
}
// Verify second org quota is now exhausted
verifyQuota(ctx, t, user, second.ID.String(), 3, 3)
// Try to create another workspace in the second org - should fail
workspace = coderdtest.CreateWorkspace(t, user, template2.ID)
build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, workspace.LatestBuild.ID)
require.Equal(t, codersdk.WorkspaceStatusFailed, build.Status)
require.Contains(t, build.Job.Error, "quota")
// Verify second org quota consumption didn't increase
verifyQuota(ctx, t, user, second.ID.String(), 3, 3)
// Verify first org quota is still exhausted
verifyQuota(ctx, t, user, first.OrganizationID.String(), 2, 2)
// Delete one workspace from the first org to free up quota
build = coderdtest.CreateWorkspaceBuild(t, user, workspaces1[0], database.WorkspaceTransitionDelete)
build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, build.ID)
require.Equal(t, codersdk.WorkspaceStatusDeleted, build.Status)
// Verify first org quota is now available again
verifyQuota(ctx, t, user, first.OrganizationID.String(), 1, 2)
// Create a workspace in the first org - should succeed
workspace = coderdtest.CreateWorkspace(t, user, template1.ID)
build = coderdtest.AwaitWorkspaceBuildJobCompleted(t, user, workspace.LatestBuild.ID)
require.Equal(t, codersdk.WorkspaceStatusRunning, build.Status)
// Verify first org quota is exhausted again
verifyQuota(ctx, t, user, first.OrganizationID.String(), 2, 2)
// Verify second org quota remains exhausted
verifyQuota(ctx, t, user, second.ID.String(), 3, 3)
})
}
// nolint:paralleltest,tparallel // Tests must run serially