mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -485,16 +485,6 @@ var (
|
||||
rbac.ResourceFile.Type: {
|
||||
policy.ActionRead,
|
||||
},
|
||||
// Needs to be able to add the prebuilds system user to the "prebuilds" group in each organization that needs prebuilt workspaces
|
||||
// so that prebuilt workspaces can be scheduled and owned in those organizations.
|
||||
rbac.ResourceGroup.Type: {
|
||||
policy.ActionRead,
|
||||
policy.ActionCreate,
|
||||
policy.ActionUpdate,
|
||||
},
|
||||
rbac.ResourceGroupMember.Type: {
|
||||
policy.ActionRead,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -6592,19 +6592,16 @@ WHERE
|
||||
organization_id = $1
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by system type
|
||||
AND CASE WHEN $2::bool THEN TRUE ELSE is_system = false END
|
||||
ORDER BY
|
||||
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
|
||||
LOWER(username) ASC OFFSET $3
|
||||
LOWER(username) ASC OFFSET $2
|
||||
LIMIT
|
||||
-- A null limit means "no limit", so 0 means return all
|
||||
NULLIF($4 :: int, 0)
|
||||
NULLIF($3 :: int, 0)
|
||||
`
|
||||
|
||||
type PaginatedOrganizationMembersParams struct {
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
IncludeSystem bool `db:"include_system" json:"include_system"`
|
||||
OffsetOpt int32 `db:"offset_opt" json:"offset_opt"`
|
||||
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
|
||||
}
|
||||
@@ -6620,12 +6617,7 @@ type PaginatedOrganizationMembersRow struct {
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, paginatedOrganizationMembers,
|
||||
arg.OrganizationID,
|
||||
arg.IncludeSystem,
|
||||
arg.OffsetOpt,
|
||||
arg.LimitOpt,
|
||||
)
|
||||
rows, err := q.db.QueryContext(ctx, paginatedOrganizationMembers, arg.OrganizationID, arg.OffsetOpt, arg.LimitOpt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -89,8 +89,6 @@ WHERE
|
||||
organization_id = @organization_id
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by system type
|
||||
AND CASE WHEN @include_system::bool THEN TRUE ELSE is_system = false END
|
||||
ORDER BY
|
||||
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
|
||||
LOWER(username) ASC OFFSET @offset_opt
|
||||
|
||||
@@ -203,7 +203,6 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
paginatedMemberRows, err := api.Database.PaginatedOrganizationMembers(ctx, database.PaginatedOrganizationMembersParams{
|
||||
OrganizationID: organization.ID,
|
||||
IncludeSystem: false,
|
||||
// #nosec G115 - Pagination limits are small and fit in int32
|
||||
LimitOpt: int32(paginationParams.Limit),
|
||||
// #nosec G115 - Pagination offsets are small and fit in int32
|
||||
|
||||
@@ -235,18 +235,12 @@ The system always maintains the desired number of prebuilt workspaces for the ac
|
||||
|
||||
### Managing resource quotas
|
||||
|
||||
To help prevent unexpected infrastructure costs, prebuilt workspaces can be used in conjunction with [resource quotas](../../users/quotas.md).
|
||||
Prebuilt workspaces can be used in conjunction with [resource quotas](../../users/quotas.md).
|
||||
Because unclaimed prebuilt workspaces are owned by the `prebuilds` user, you can:
|
||||
|
||||
1. Configure quotas for any group that includes this user.
|
||||
1. Set appropriate limits to balance prebuilt workspace availability with resource constraints.
|
||||
|
||||
When prebuilt workspaces are configured for an organization, Coder creates a "prebuilds" group in that organization and adds the prebuilds user to it. This group has a default quota allowance of 0, which you should adjust based on your needs:
|
||||
|
||||
- **Set a quota allowance** on the "prebuilds" group to control how many prebuilt workspaces can be provisioned
|
||||
- **Monitor usage** to ensure the quota is appropriate for your desired number of prebuilt instances
|
||||
- **Adjust as needed** based on your template costs and desired prebuilt workspace pool size
|
||||
|
||||
If a quota is exceeded, the prebuilt workspace will fail provisioning the same way other workspaces do.
|
||||
|
||||
### Template configuration best practices
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user