diff --git a/cli/user_delete_test.go b/cli/user_delete_test.go index 9ee546ca7a925..58b02bb506a1f 100644 --- a/cli/user_delete_test.go +++ b/cli/user_delete_test.go @@ -78,37 +78,6 @@ func TestUserDelete(t *testing.T) { pty.ExpectMatch("coolin") }) - t.Run("UserID", func(t *testing.T) { - t.Parallel() - ctx := context.Background() - client := coderdtest.New(t, nil) - owner := coderdtest.CreateFirstUser(t, client) - userAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleUserAdmin()) - - pw, err := cryptorand.String(16) - require.NoError(t, err) - - user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "colin5@coder.com", - Username: "coolin", - Password: pw, - UserLoginType: codersdk.LoginTypePassword, - OrganizationID: owner.OrganizationID, - DisableLogin: false, - }) - require.NoError(t, err) - - inv, root := clitest.New(t, "users", "delete", user.ID.String()) - clitest.SetupConfig(t, userAdmin, root) - pty := ptytest.New(t).Attach(inv) - errC := make(chan error) - go func() { - errC <- inv.Run() - }() - require.NoError(t, <-errC) - pty.ExpectMatch("coolin") - }) - // TODO: reenable this test case. Fetching users without perms returns a // "user "testuser@coder.com" must be a member of at least one organization" // error. diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 135703bb0ba71..055a8a423bc78 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2214,18 +2214,14 @@ func (q *querier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, return q.db.GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx, templateIds) } -func (q *querier) GetWorkspaces(ctx context.Context, arg database.GetWorkspacesParams) ([]database.GetWorkspacesRow, error) { - prep, err := prepareSQLFilter(ctx, q.auth, rbac.ActionRead, rbac.ResourceWorkspace.Type) - if err != nil { - return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) - } - return q.db.GetAuthorizedWorkspaces(ctx, arg, prep) -} - func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.Workspace, error) { return q.db.GetWorkspacesEligibleForTransition(ctx, now) } +func (q *querier) GetWorkspacesWithSummary(ctx context.Context, arg database.GetWorkspacesWithSummaryParams) ([]database.GetWorkspacesWithSummaryRow, error) { + return q.db.GetWorkspacesWithSummary(ctx, arg) +} + func (q *querier) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { return insert(q.log, q.auth, rbac.ResourceAPIKey.WithOwner(arg.UserID.String()), @@ -3432,6 +3428,14 @@ func (q *querier) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetW return q.GetWorkspaces(ctx, arg) } +func (q *querier) GetWorkspaces(ctx context.Context, arg database.GetWorkspacesParams) ([]database.GetWorkspacesRow, error) { + prep, err := prepareSQLFilter(ctx, q.auth, rbac.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) + } + return q.db.GetAuthorizedWorkspaces(ctx, arg, prep) +} + // GetAuthorizedUsers is not required for dbauthz since GetUsers is already // authenticated. func (q *querier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, _ rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 3e6b0e1d15ab4..2df269db1053c 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -345,10 +345,10 @@ func mapAgentStatus(dbAgent database.WorkspaceAgent, agentInactiveDisconnectTime return status } -func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspaces []database.Workspace, count int64) []database.GetWorkspacesRow { - rows := make([]database.GetWorkspacesRow, 0, len(workspaces)) +func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspaces []database.Workspace, count int64) []database.GetWorkspacesWithSummaryRow { + rows := make([]database.GetWorkspacesWithSummaryRow, 0, len(workspaces)) for _, w := range workspaces { - wr := database.GetWorkspacesRow{ + wr := database.GetWorkspacesWithSummaryRow{ ID: w.ID, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, @@ -389,6 +389,12 @@ func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspac rows = append(rows, wr) } + + // Append a technical row with summary + rows = append(rows, database.GetWorkspacesWithSummaryRow{ + Count: count, + Name: "*TECHNICAL_ROW*", + }) return rows } @@ -767,6 +773,260 @@ func tagsSubset(m1, m2 map[string]string) bool { // default tags when no tag is specified for a provisioner or job var tagsUntagged = provisionersdk.MutateTags(uuid.Nil, nil) +func (q *FakeQuerier) getAuthorizedWorkspacesWithSummary(ctx context.Context, arg database.GetWorkspacesWithSummaryParams, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesWithSummaryRow, error) { + if err := validateDatabaseType(arg); err != nil { + return nil, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaces := make([]database.Workspace, 0) + for _, workspace := range q.workspaces { + if arg.OwnerID != uuid.Nil && workspace.OwnerID != arg.OwnerID { + continue + } + + if arg.OwnerUsername != "" { + owner, err := q.getUserByIDNoLock(workspace.OwnerID) + if err == nil && !strings.EqualFold(arg.OwnerUsername, owner.Username) { + continue + } + } + + if arg.TemplateName != "" { + template, err := q.getTemplateByIDNoLock(ctx, workspace.TemplateID) + if err == nil && !strings.EqualFold(arg.TemplateName, template.Name) { + continue + } + } + + if arg.UsingActive.Valid { + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) + if err != nil { + return nil, xerrors.Errorf("get latest build: %w", err) + } + + template, err := q.getTemplateByIDNoLock(ctx, workspace.TemplateID) + if err != nil { + return nil, xerrors.Errorf("get template: %w", err) + } + + updated := build.TemplateVersionID == template.ActiveVersionID + if arg.UsingActive.Bool != updated { + continue + } + } + + if !arg.Deleted && workspace.Deleted { + continue + } + + if arg.Name != "" && !strings.Contains(strings.ToLower(workspace.Name), strings.ToLower(arg.Name)) { + continue + } + + if !arg.LastUsedBefore.IsZero() { + if workspace.LastUsedAt.After(arg.LastUsedBefore) { + continue + } + } + + if !arg.LastUsedAfter.IsZero() { + if workspace.LastUsedAt.Before(arg.LastUsedAfter) { + continue + } + } + + if arg.Status != "" { + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) + if err != nil { + return nil, xerrors.Errorf("get latest build: %w", err) + } + + job, err := q.getProvisionerJobByIDNoLock(ctx, build.JobID) + if err != nil { + return nil, xerrors.Errorf("get provisioner job: %w", err) + } + + // This logic should match the logic in the workspace.sql file. + var statusMatch bool + switch database.WorkspaceStatus(arg.Status) { + case database.WorkspaceStatusStarting: + statusMatch = job.JobStatus == database.ProvisionerJobStatusRunning && + build.Transition == database.WorkspaceTransitionStart + case database.WorkspaceStatusStopping: + statusMatch = job.JobStatus == database.ProvisionerJobStatusRunning && + build.Transition == database.WorkspaceTransitionStop + case database.WorkspaceStatusDeleting: + statusMatch = job.JobStatus == database.ProvisionerJobStatusRunning && + build.Transition == database.WorkspaceTransitionDelete + + case "started": + statusMatch = job.JobStatus == database.ProvisionerJobStatusSucceeded && + build.Transition == database.WorkspaceTransitionStart + case database.WorkspaceStatusDeleted: + statusMatch = job.JobStatus == database.ProvisionerJobStatusSucceeded && + build.Transition == database.WorkspaceTransitionDelete + case database.WorkspaceStatusStopped: + statusMatch = job.JobStatus == database.ProvisionerJobStatusSucceeded && + build.Transition == database.WorkspaceTransitionStop + case database.WorkspaceStatusRunning: + statusMatch = job.JobStatus == database.ProvisionerJobStatusSucceeded && + build.Transition == database.WorkspaceTransitionStart + default: + statusMatch = job.JobStatus == database.ProvisionerJobStatus(arg.Status) + } + if !statusMatch { + continue + } + } + + if arg.HasAgent != "" { + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) + if err != nil { + return nil, xerrors.Errorf("get latest build: %w", err) + } + + job, err := q.getProvisionerJobByIDNoLock(ctx, build.JobID) + if err != nil { + return nil, xerrors.Errorf("get provisioner job: %w", err) + } + + workspaceResources, err := q.getWorkspaceResourcesByJobIDNoLock(ctx, job.ID) + if err != nil { + return nil, xerrors.Errorf("get workspace resources: %w", err) + } + + var workspaceResourceIDs []uuid.UUID + for _, wr := range workspaceResources { + workspaceResourceIDs = append(workspaceResourceIDs, wr.ID) + } + + workspaceAgents, err := q.getWorkspaceAgentsByResourceIDsNoLock(ctx, workspaceResourceIDs) + if err != nil { + return nil, xerrors.Errorf("get workspace agents: %w", err) + } + + var hasAgentMatched bool + for _, wa := range workspaceAgents { + if mapAgentStatus(wa, arg.AgentInactiveDisconnectTimeoutSeconds) == arg.HasAgent { + hasAgentMatched = true + } + } + + if !hasAgentMatched { + continue + } + } + + if arg.Dormant && !workspace.DormantAt.Valid { + continue + } + + if len(arg.TemplateIDs) > 0 { + match := false + for _, id := range arg.TemplateIDs { + if workspace.TemplateID == id { + match = true + break + } + } + if !match { + continue + } + } + + // If the filter exists, ensure the object is authorized. + if prepared != nil && prepared.Authorize(ctx, workspace.RBACObject()) != nil { + continue + } + workspaces = append(workspaces, workspace) + } + + // Sort workspaces (ORDER BY) + isRunning := func(build database.WorkspaceBuild, job database.ProvisionerJob) bool { + return job.CompletedAt.Valid && !job.CanceledAt.Valid && !job.Error.Valid && build.Transition == database.WorkspaceTransitionStart + } + + preloadedWorkspaceBuilds := map[uuid.UUID]database.WorkspaceBuild{} + preloadedProvisionerJobs := map[uuid.UUID]database.ProvisionerJob{} + preloadedUsers := map[uuid.UUID]database.User{} + + for _, w := range workspaces { + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, w.ID) + if err == nil { + preloadedWorkspaceBuilds[w.ID] = build + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("get latest build: %w", err) + } + + job, err := q.getProvisionerJobByIDNoLock(ctx, build.JobID) + if err == nil { + preloadedProvisionerJobs[w.ID] = job + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("get provisioner job: %w", err) + } + + user, err := q.getUserByIDNoLock(w.OwnerID) + if err == nil { + preloadedUsers[w.ID] = user + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("get user: %w", err) + } + } + + sort.Slice(workspaces, func(i, j int) bool { + w1 := workspaces[i] + w2 := workspaces[j] + + // Order by: favorite first + if arg.RequesterID == w1.OwnerID && w1.Favorite { + return true + } + if arg.RequesterID == w2.OwnerID && w2.Favorite { + return false + } + + // Order by: running + w1IsRunning := isRunning(preloadedWorkspaceBuilds[w1.ID], preloadedProvisionerJobs[w1.ID]) + w2IsRunning := isRunning(preloadedWorkspaceBuilds[w2.ID], preloadedProvisionerJobs[w2.ID]) + + if w1IsRunning && !w2IsRunning { + return true + } + + if !w1IsRunning && w2IsRunning { + return false + } + + // Order by: usernames + if strings.Compare(preloadedUsers[w1.ID].Username, preloadedUsers[w2.ID].Username) < 0 { + return true + } + + // Order by: workspace names + return strings.Compare(w1.Name, w2.Name) < 0 + }) + + beforePageCount := len(workspaces) + + if arg.Offset > 0 { + if int(arg.Offset) > len(workspaces) { + return []database.GetWorkspacesWithSummaryRow{}, nil + } + workspaces = workspaces[arg.Offset:] + } + if arg.Limit > 0 { + if int(arg.Limit) > len(workspaces) { + return q.convertToWorkspaceRowsNoLock(ctx, workspaces, int64(beforePageCount)), nil + } + workspaces = workspaces[:arg.Limit] + } + + return q.convertToWorkspaceRowsNoLock(ctx, workspaces, int64(beforePageCount)), nil +} + func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } @@ -5074,16 +5334,6 @@ func (q *FakeQuerier) GetWorkspaceUniqueOwnerCountByTemplateIDs(_ context.Contex return resp, nil } -func (q *FakeQuerier) GetWorkspaces(ctx context.Context, arg database.GetWorkspacesParams) ([]database.GetWorkspacesRow, error) { - if err := validateDatabaseType(arg); err != nil { - return nil, err - } - - // A nil auth filter means no auth filter. - workspaceRows, err := q.GetAuthorizedWorkspaces(ctx, arg, nil) - return workspaceRows, err -} - func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.Workspace, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -5136,6 +5386,10 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no return workspaces, nil } +func (q *FakeQuerier) GetWorkspacesWithSummary(ctx context.Context, arg database.GetWorkspacesWithSummaryParams) ([]database.GetWorkspacesWithSummaryRow, error) { + return q.getAuthorizedWorkspacesWithSummary(ctx, arg, nil) +} + func (q *FakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { if err := validateDatabaseType(arg); err != nil { return database.APIKey{}, err @@ -8040,250 +8294,25 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } } - workspaces := make([]database.Workspace, 0) - for _, workspace := range q.workspaces { - if arg.OwnerID != uuid.Nil && workspace.OwnerID != arg.OwnerID { - continue - } - - if arg.OwnerUsername != "" { - owner, err := q.getUserByIDNoLock(workspace.OwnerID) - if err == nil && !strings.EqualFold(arg.OwnerUsername, owner.Username) { - continue - } - } - - if arg.TemplateName != "" { - template, err := q.getTemplateByIDNoLock(ctx, workspace.TemplateID) - if err == nil && !strings.EqualFold(arg.TemplateName, template.Name) { - continue - } - } - - if arg.UsingActive.Valid { - build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) - if err != nil { - return nil, xerrors.Errorf("get latest build: %w", err) - } - - template, err := q.getTemplateByIDNoLock(ctx, workspace.TemplateID) - if err != nil { - return nil, xerrors.Errorf("get template: %w", err) - } - - updated := build.TemplateVersionID == template.ActiveVersionID - if arg.UsingActive.Bool != updated { - continue - } - } - - if !arg.Deleted && workspace.Deleted { - continue - } - - if arg.Name != "" && !strings.Contains(strings.ToLower(workspace.Name), strings.ToLower(arg.Name)) { - continue - } - - if !arg.LastUsedBefore.IsZero() { - if workspace.LastUsedAt.After(arg.LastUsedBefore) { - continue - } - } - - if !arg.LastUsedAfter.IsZero() { - if workspace.LastUsedAt.Before(arg.LastUsedAfter) { - continue - } - } - - if arg.Status != "" { - build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) - if err != nil { - return nil, xerrors.Errorf("get latest build: %w", err) - } - - job, err := q.getProvisionerJobByIDNoLock(ctx, build.JobID) - if err != nil { - return nil, xerrors.Errorf("get provisioner job: %w", err) - } - - // This logic should match the logic in the workspace.sql file. - var statusMatch bool - switch database.WorkspaceStatus(arg.Status) { - case database.WorkspaceStatusStarting: - statusMatch = job.JobStatus == database.ProvisionerJobStatusRunning && - build.Transition == database.WorkspaceTransitionStart - case database.WorkspaceStatusStopping: - statusMatch = job.JobStatus == database.ProvisionerJobStatusRunning && - build.Transition == database.WorkspaceTransitionStop - case database.WorkspaceStatusDeleting: - statusMatch = job.JobStatus == database.ProvisionerJobStatusRunning && - build.Transition == database.WorkspaceTransitionDelete - - case "started": - statusMatch = job.JobStatus == database.ProvisionerJobStatusSucceeded && - build.Transition == database.WorkspaceTransitionStart - case database.WorkspaceStatusDeleted: - statusMatch = job.JobStatus == database.ProvisionerJobStatusSucceeded && - build.Transition == database.WorkspaceTransitionDelete - case database.WorkspaceStatusStopped: - statusMatch = job.JobStatus == database.ProvisionerJobStatusSucceeded && - build.Transition == database.WorkspaceTransitionStop - case database.WorkspaceStatusRunning: - statusMatch = job.JobStatus == database.ProvisionerJobStatusSucceeded && - build.Transition == database.WorkspaceTransitionStart - default: - statusMatch = job.JobStatus == database.ProvisionerJobStatus(arg.Status) - } - if !statusMatch { - continue - } - } - - if arg.HasAgent != "" { - build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) - if err != nil { - return nil, xerrors.Errorf("get latest build: %w", err) - } - - job, err := q.getProvisionerJobByIDNoLock(ctx, build.JobID) - if err != nil { - return nil, xerrors.Errorf("get provisioner job: %w", err) - } - - workspaceResources, err := q.getWorkspaceResourcesByJobIDNoLock(ctx, job.ID) - if err != nil { - return nil, xerrors.Errorf("get workspace resources: %w", err) - } - - var workspaceResourceIDs []uuid.UUID - for _, wr := range workspaceResources { - workspaceResourceIDs = append(workspaceResourceIDs, wr.ID) - } - - workspaceAgents, err := q.getWorkspaceAgentsByResourceIDsNoLock(ctx, workspaceResourceIDs) - if err != nil { - return nil, xerrors.Errorf("get workspace agents: %w", err) - } - - var hasAgentMatched bool - for _, wa := range workspaceAgents { - if mapAgentStatus(wa, arg.AgentInactiveDisconnectTimeoutSeconds) == arg.HasAgent { - hasAgentMatched = true - } - } - - if !hasAgentMatched { - continue - } - } - - if arg.Dormant && !workspace.DormantAt.Valid { - continue - } - - if len(arg.TemplateIDs) > 0 { - match := false - for _, id := range arg.TemplateIDs { - if workspace.TemplateID == id { - match = true - break - } - } - if !match { - continue - } - } - - // If the filter exists, ensure the object is authorized. - if prepared != nil && prepared.Authorize(ctx, workspace.RBACObject()) != nil { - continue - } - workspaces = append(workspaces, workspace) + workspaceRows, err := q.getAuthorizedWorkspacesWithSummary(ctx, database.GetWorkspacesWithSummaryParams(arg), prepared) + if err != nil { + return nil, err } - - // Sort workspaces (ORDER BY) - isRunning := func(build database.WorkspaceBuild, job database.ProvisionerJob) bool { - return job.CompletedAt.Valid && !job.CanceledAt.Valid && !job.Error.Valid && build.Transition == database.WorkspaceTransitionStart + workspaceRows = workspaceRows[:len(workspaceRows)-1] + rows := make([]database.GetWorkspacesRow, 0, len(workspaceRows)) + for _, r := range workspaceRows { + rows = append(rows, database.GetWorkspacesRow(r)) } + return rows, nil +} - preloadedWorkspaceBuilds := map[uuid.UUID]database.WorkspaceBuild{} - preloadedProvisionerJobs := map[uuid.UUID]database.ProvisionerJob{} - preloadedUsers := map[uuid.UUID]database.User{} - - for _, w := range workspaces { - build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, w.ID) - if err == nil { - preloadedWorkspaceBuilds[w.ID] = build - } else if !errors.Is(err, sql.ErrNoRows) { - return nil, xerrors.Errorf("get latest build: %w", err) - } - - job, err := q.getProvisionerJobByIDNoLock(ctx, build.JobID) - if err == nil { - preloadedProvisionerJobs[w.ID] = job - } else if !errors.Is(err, sql.ErrNoRows) { - return nil, xerrors.Errorf("get provisioner job: %w", err) - } - - user, err := q.getUserByIDNoLock(w.OwnerID) - if err == nil { - preloadedUsers[w.ID] = user - } else if !errors.Is(err, sql.ErrNoRows) { - return nil, xerrors.Errorf("get user: %w", err) - } - } - - sort.Slice(workspaces, func(i, j int) bool { - w1 := workspaces[i] - w2 := workspaces[j] - - // Order by: favorite first - if arg.RequesterID == w1.OwnerID && w1.Favorite { - return true - } - if arg.RequesterID == w2.OwnerID && w2.Favorite { - return false - } - - // Order by: running - w1IsRunning := isRunning(preloadedWorkspaceBuilds[w1.ID], preloadedProvisionerJobs[w1.ID]) - w2IsRunning := isRunning(preloadedWorkspaceBuilds[w2.ID], preloadedProvisionerJobs[w2.ID]) - - if w1IsRunning && !w2IsRunning { - return true - } - - if !w1IsRunning && w2IsRunning { - return false - } - - // Order by: usernames - if strings.Compare(preloadedUsers[w1.ID].Username, preloadedUsers[w2.ID].Username) < 0 { - return true - } - - // Order by: workspace names - return strings.Compare(w1.Name, w2.Name) < 0 - }) - - beforePageCount := len(workspaces) - - if arg.Offset > 0 { - if int(arg.Offset) > len(workspaces) { - return []database.GetWorkspacesRow{}, nil - } - workspaces = workspaces[arg.Offset:] - } - if arg.Limit > 0 { - if int(arg.Limit) > len(workspaces) { - return q.convertToWorkspaceRowsNoLock(ctx, workspaces, int64(beforePageCount)), nil - } - workspaces = workspaces[:arg.Limit] +func (q *FakeQuerier) GetWorkspaces(ctx context.Context, arg database.GetWorkspacesParams) ([]database.GetWorkspacesRow, error) { + if err := validateDatabaseType(arg); err != nil { + return nil, err } - return q.convertToWorkspaceRowsNoLock(ctx, workspaces, int64(beforePageCount)), nil + // A nil auth filter means no auth filter. + return q.GetAuthorizedWorkspaces(ctx, arg, nil) } func (q *FakeQuerier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index efbf01848020e..32a87ae4d9bef 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1369,13 +1369,6 @@ func (m metricsStore) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Cont return r0, r1 } -func (m metricsStore) GetWorkspaces(ctx context.Context, arg database.GetWorkspacesParams) ([]database.GetWorkspacesRow, error) { - start := time.Now() - workspaces, err := m.s.GetWorkspaces(ctx, arg) - m.queryLatencies.WithLabelValues("GetWorkspaces").Observe(time.Since(start).Seconds()) - return workspaces, err -} - func (m metricsStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.Workspace, error) { start := time.Now() workspaces, err := m.s.GetWorkspacesEligibleForTransition(ctx, now) @@ -1383,6 +1376,13 @@ func (m metricsStore) GetWorkspacesEligibleForTransition(ctx context.Context, no return workspaces, err } +func (m metricsStore) GetWorkspacesWithSummary(ctx context.Context, arg database.GetWorkspacesWithSummaryParams) ([]database.GetWorkspacesWithSummaryRow, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspacesWithSummary(ctx, arg) + m.queryLatencies.WithLabelValues("GetWorkspacesWithSummary").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { start := time.Now() key, err := m.s.InsertAPIKey(ctx, arg) @@ -2255,6 +2255,13 @@ func (m metricsStore) GetAuthorizedWorkspaces(ctx context.Context, arg database. return workspaces, err } +func (m metricsStore) GetWorkspaces(ctx context.Context, arg database.GetWorkspacesParams) ([]database.GetWorkspacesRow, error) { + start := time.Now() + workspaces, err := m.s.GetWorkspaces(ctx, arg) + m.queryLatencies.WithLabelValues("GetWorkspaces").Observe(time.Since(start).Seconds()) + return workspaces, err +} + func (m metricsStore) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { start := time.Now() r0, r1 := m.s.GetAuthorizedUsers(ctx, arg, prepared) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 5bcca0b1a22b4..d65c775eccb9d 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2896,6 +2896,21 @@ func (mr *MockStoreMockRecorder) GetWorkspacesEligibleForTransition(arg0, arg1 a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesEligibleForTransition", reflect.TypeOf((*MockStore)(nil).GetWorkspacesEligibleForTransition), arg0, arg1) } +// GetWorkspacesWithSummary mocks base method. +func (m *MockStore) GetWorkspacesWithSummary(arg0 context.Context, arg1 database.GetWorkspacesWithSummaryParams) ([]database.GetWorkspacesWithSummaryRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspacesWithSummary", arg0, arg1) + ret0, _ := ret[0].([]database.GetWorkspacesWithSummaryRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspacesWithSummary indicates an expected call of GetWorkspacesWithSummary. +func (mr *MockStoreMockRecorder) GetWorkspacesWithSummary(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesWithSummary", reflect.TypeOf((*MockStore)(nil).GetWorkspacesWithSummary), arg0, arg1) +} + // InTx mocks base method. func (m *MockStore) InTx(arg0 func(database.Store) error, arg1 *sql.TxOptions) error { m.ctrl.T.Helper() diff --git a/coderd/database/gentest/modelqueries_test.go b/coderd/database/gentest/modelqueries_test.go index 52a99b54405ec..148c6de69acb1 100644 --- a/coderd/database/gentest/modelqueries_test.go +++ b/coderd/database/gentest/modelqueries_test.go @@ -23,9 +23,9 @@ func TestCustomQueriesSyncedRowScan(t *testing.T) { t.Parallel() funcsToTrack := map[string]string{ - "GetTemplatesWithFilter": "GetAuthorizedTemplates", - "GetWorkspaces": "GetAuthorizedWorkspaces", - "GetUsers": "GetAuthorizedUsers", + "GetTemplatesWithFilter": "GetAuthorizedTemplates", + "GetWorkspacesWithSummary": "GetAuthorizedWorkspaces", + "GetUsers": "GetAuthorizedUsers", } // Scan custom diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 34d43ecd924bd..20bca49b49318 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -194,8 +194,14 @@ func (q *sqlQuerier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([ type workspaceQuerier interface { GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]GetWorkspacesRow, error) + GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) } +type ( + GetWorkspacesParams GetWorkspacesWithSummaryParams + GetWorkspacesRow GetWorkspacesWithSummaryRow +) + // GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access. // This code is copied from `GetWorkspaces` and adds the authorized filter WHERE // clause. @@ -207,7 +213,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa // In order to properly use ORDER BY, OFFSET, and LIMIT, we need to inject the // authorizedFilter between the end of the where clause and those statements. - filtered, err := insertAuthorizedFilter(getWorkspaces, fmt.Sprintf(" AND %s", authorizedFilter)) + filtered, err := insertAuthorizedFilter(getWorkspacesWithSummary, fmt.Sprintf(" AND %s", authorizedFilter)) if err != nil { return nil, xerrors.Errorf("insert authorized filter: %w", err) } @@ -258,6 +264,11 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, + &i.Username, + &i.LatestBuildCompletedAt, + &i.LatestBuildCanceledAt, + &i.LatestBuildError, + &i.LatestBuildTransition, &i.Count, ); err != nil { return nil, err @@ -270,9 +281,24 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa if err := rows.Err(); err != nil { return nil, err } + + items = items[:len(items)-1] // Remove summary row return items, nil } +func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) { + rows, err := q.GetWorkspacesWithSummary(ctx, GetWorkspacesWithSummaryParams(arg)) + if err != nil { + return nil, err + } + rows = rows[:len(rows)-1] + translated := make([]GetWorkspacesRow, 0, len(rows)) + for _, r := range rows { + translated = append(translated, GetWorkspacesRow(r)) + } + return translated, nil +} + type userQuerier interface { GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, prepared rbac.PreparedAuthorized) ([]GetUsersRow, error) } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 92ee81f85fd91..9955f5f325fb6 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -275,8 +275,8 @@ type sqlcQuerier interface { GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResource, error) GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) - GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]Workspace, error) + GetWorkspacesWithSummary(ctx context.Context, arg GetWorkspacesWithSummaryParams) ([]GetWorkspacesWithSummaryRow, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) // We use the organization_id as the id // for simplicity since all users is diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6ddf88be6ee24..07619e12e16c1 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11770,13 +11770,123 @@ func (q *sqlQuerier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Conte return items, nil } -const getWorkspaces = `-- name: GetWorkspaces :many +const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many +SELECT + workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite +FROM + workspaces +LEFT JOIN + workspace_builds ON workspace_builds.workspace_id = workspaces.id +INNER JOIN + provisioner_jobs ON workspace_builds.job_id = provisioner_jobs.id +INNER JOIN + templates ON workspaces.template_id = templates.id +WHERE + workspace_builds.build_number = ( + SELECT + MAX(build_number) + FROM + workspace_builds + WHERE + workspace_builds.workspace_id = workspaces.id + ) AND + + ( + -- If the workspace build was a start transition, the workspace is + -- potentially eligible for autostop if it's past the deadline. The + -- deadline is computed at build time upon success and is bumped based + -- on activity (up the max deadline if set). We don't need to check + -- license here since that's done when the values are written to the build. + ( + workspace_builds.transition = 'start'::workspace_transition AND + workspace_builds.deadline IS NOT NULL AND + workspace_builds.deadline < $1 :: timestamptz + ) OR + + -- If the workspace build was a stop transition, the workspace is + -- potentially eligible for autostart if it has a schedule set. The + -- caller must check if the template allows autostart in a license-aware + -- fashion as we cannot check it here. + ( + workspace_builds.transition = 'stop'::workspace_transition AND + workspaces.autostart_schedule IS NOT NULL + ) OR + + -- If the workspace's most recent job resulted in an error + -- it may be eligible for failed stop. + ( + provisioner_jobs.error IS NOT NULL AND + provisioner_jobs.error != '' AND + workspace_builds.transition = 'start'::workspace_transition + ) OR + + -- If the workspace's template has an inactivity_ttl set + -- it may be eligible for dormancy. + ( + templates.time_til_dormant > 0 AND + workspaces.dormant_at IS NULL + ) OR + + -- If the workspace's template has a time_til_dormant_autodelete set + -- and the workspace is already dormant. + ( + templates.time_til_dormant_autodelete > 0 AND + workspaces.dormant_at IS NOT NULL + ) + ) AND workspaces.deleted = 'false' +` + +func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForTransition, now) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + &i.LastUsedAt, + &i.DormantAt, + &i.DeletingAt, + &i.AutomaticUpdates, + &i.Favorite, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getWorkspacesWithSummary = `-- name: GetWorkspacesWithSummary :many +WITH filtered_workspaces AS ( SELECT workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, COALESCE(template.name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, - COUNT(*) OVER () as count + users.username as username, + latest_build.completed_at as latest_build_completed_at, + latest_build.canceled_at as latest_build_canceled_at, + latest_build.error as latest_build_error, + latest_build.transition as latest_build_transition FROM workspaces JOIN @@ -11960,25 +12070,76 @@ WHERE END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter -ORDER BY - -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN workspaces.owner_id = $14 AND workspaces.favorite THEN 0 ELSE 1 END ASC, - (latest_build.completed_at IS NOT NULL AND - latest_build.canceled_at IS NULL AND - latest_build.error IS NULL AND - latest_build.transition = 'start'::workspace_transition) DESC, - LOWER(users.username) ASC, - LOWER(workspaces.name) ASC -LIMIT - CASE - WHEN $16 :: integer > 0 THEN - $16 - END -OFFSET - $15 +), filtered_workspaces_order AS ( + SELECT + fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.template_name, fw.template_version_id, fw.template_version_name, fw.username, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition + FROM + filtered_workspaces fw + ORDER BY + -- To ensure that 'favorite' workspaces show up first in the list only for their owner. + CASE WHEN owner_id = $14 AND favorite THEN 0 ELSE 1 END ASC, + (latest_build_completed_at IS NOT NULL AND + latest_build_canceled_at IS NULL AND + latest_build_error IS NULL AND + latest_build_transition = 'start'::workspace_transition) DESC, + LOWER(username) ASC, + LOWER(name) ASC + LIMIT + CASE + WHEN $16 :: integer > 0 THEN + $16 + END + OFFSET + $15 +), filtered_workspaces_order_with_summary AS ( + SELECT + fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.template_name, fwo.template_version_id, fwo.template_version_name, fwo.username, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition + FROM + filtered_workspaces_order fwo + -- Always return a technical summary row with total count of workspaces. + -- It is used to present the correct count if pagination goes beyond the offset. + UNION ALL + SELECT + '00000000-0000-0000-0000-000000000000'::uuid, -- id + '0001-01-01 00:00:00+00'::timestamp, -- created_at + '0001-01-01 00:00:00+00'::timestamp, -- updated_at + '00000000-0000-0000-0000-000000000000'::uuid, -- owner_id + '00000000-0000-0000-0000-000000000000'::uuid, -- organization_id + '00000000-0000-0000-0000-000000000000'::uuid, -- template_id + false, -- deleted + '**TECHNICAL_ROW**', -- name + '', -- autostart_schedule + 0, -- ttl + '0001-01-01 00:00:00+00'::timestamp, -- last_used_at + '0001-01-01 00:00:00+00'::timestamp, -- dormant_at + '0001-01-01 00:00:00+00'::timestamp, -- deleting_at + 'never'::automatic_updates, -- automatic_updates + false, -- favorite + -- Extra columns added to ` + "`" + `filtered_workspaces` + "`" + ` + '', -- template_name + '00000000-0000-0000-0000-000000000000'::uuid, -- template_version_id + '', -- template_version_name + '', -- username + '0001-01-01 00:00:00+00'::timestamp, -- latest_build_completed_at, + '0001-01-01 00:00:00+00'::timestamp, -- latest_build_canceled_at, + '', -- latest_build_error + 'start'::workspace_transition -- latest_build_transition +), total_count AS ( + SELECT + count(*) AS count + FROM + filtered_workspaces +) +SELECT + fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.template_name, fwos.template_version_id, fwos.template_version_name, fwos.username, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, + tc.count +FROM + filtered_workspaces_order_with_summary fwos +CROSS JOIN + total_count tc ` -type GetWorkspacesParams struct { +type GetWorkspacesWithSummaryParams struct { Deleted bool `db:"deleted" json:"deleted"` Status string `db:"status" json:"status"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` @@ -11997,30 +12158,35 @@ type GetWorkspacesParams struct { Limit int32 `db:"limit_" json:"limit_"` } -type GetWorkspacesRow struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` - Name string `db:"name" json:"name"` - AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` - Ttl sql.NullInt64 `db:"ttl" json:"ttl"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` - DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` - AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` - Favorite bool `db:"favorite" json:"favorite"` - TemplateName string `db:"template_name" json:"template_name"` - TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` - TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` - Count int64 `db:"count" json:"count"` -} - -func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) { - rows, err := q.db.QueryContext(ctx, getWorkspaces, +type GetWorkspacesWithSummaryRow struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` + DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` + AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + Favorite bool `db:"favorite" json:"favorite"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` + Username string `db:"username" json:"username"` + LatestBuildCompletedAt sql.NullTime `db:"latest_build_completed_at" json:"latest_build_completed_at"` + LatestBuildCanceledAt sql.NullTime `db:"latest_build_canceled_at" json:"latest_build_canceled_at"` + LatestBuildError sql.NullString `db:"latest_build_error" json:"latest_build_error"` + LatestBuildTransition WorkspaceTransition `db:"latest_build_transition" json:"latest_build_transition"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) GetWorkspacesWithSummary(ctx context.Context, arg GetWorkspacesWithSummaryParams) ([]GetWorkspacesWithSummaryRow, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesWithSummary, arg.Deleted, arg.Status, arg.OwnerID, @@ -12042,9 +12208,9 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) return nil, err } defer rows.Close() - var items []GetWorkspacesRow + var items []GetWorkspacesWithSummaryRow for rows.Next() { - var i GetWorkspacesRow + var i GetWorkspacesWithSummaryRow if err := rows.Scan( &i.ID, &i.CreatedAt, @@ -12064,6 +12230,11 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, + &i.Username, + &i.LatestBuildCompletedAt, + &i.LatestBuildCanceledAt, + &i.LatestBuildError, + &i.LatestBuildTransition, &i.Count, ); err != nil { return nil, err @@ -12079,111 +12250,6 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) return items, nil } -const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many -SELECT - workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite -FROM - workspaces -LEFT JOIN - workspace_builds ON workspace_builds.workspace_id = workspaces.id -INNER JOIN - provisioner_jobs ON workspace_builds.job_id = provisioner_jobs.id -INNER JOIN - templates ON workspaces.template_id = templates.id -WHERE - workspace_builds.build_number = ( - SELECT - MAX(build_number) - FROM - workspace_builds - WHERE - workspace_builds.workspace_id = workspaces.id - ) AND - - ( - -- If the workspace build was a start transition, the workspace is - -- potentially eligible for autostop if it's past the deadline. The - -- deadline is computed at build time upon success and is bumped based - -- on activity (up the max deadline if set). We don't need to check - -- license here since that's done when the values are written to the build. - ( - workspace_builds.transition = 'start'::workspace_transition AND - workspace_builds.deadline IS NOT NULL AND - workspace_builds.deadline < $1 :: timestamptz - ) OR - - -- If the workspace build was a stop transition, the workspace is - -- potentially eligible for autostart if it has a schedule set. The - -- caller must check if the template allows autostart in a license-aware - -- fashion as we cannot check it here. - ( - workspace_builds.transition = 'stop'::workspace_transition AND - workspaces.autostart_schedule IS NOT NULL - ) OR - - -- If the workspace's most recent job resulted in an error - -- it may be eligible for failed stop. - ( - provisioner_jobs.error IS NOT NULL AND - provisioner_jobs.error != '' AND - workspace_builds.transition = 'start'::workspace_transition - ) OR - - -- If the workspace's template has an inactivity_ttl set - -- it may be eligible for dormancy. - ( - templates.time_til_dormant > 0 AND - workspaces.dormant_at IS NULL - ) OR - - -- If the workspace's template has a time_til_dormant_autodelete set - -- and the workspace is already dormant. - ( - templates.time_til_dormant_autodelete > 0 AND - workspaces.dormant_at IS NOT NULL - ) - ) AND workspaces.deleted = 'false' -` - -func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]Workspace, error) { - rows, err := q.db.QueryContext(ctx, getWorkspacesEligibleForTransition, now) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Workspace - for rows.Next() { - var i Workspace - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.OwnerID, - &i.OrganizationID, - &i.TemplateID, - &i.Deleted, - &i.Name, - &i.AutostartSchedule, - &i.Ttl, - &i.LastUsedAt, - &i.DormantAt, - &i.DeletingAt, - &i.AutomaticUpdates, - &i.Favorite, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const insertWorkspace = `-- name: InsertWorkspace :one INSERT INTO workspaces ( diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 592aefb1acce9..18583e67d4cda 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -76,13 +76,18 @@ WHERE ) ); --- name: GetWorkspaces :many +-- name: GetWorkspacesWithSummary :many +WITH filtered_workspaces AS ( SELECT workspaces.*, COALESCE(template.name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, - COUNT(*) OVER () as count + users.username as username, + latest_build.completed_at as latest_build_completed_at, + latest_build.canceled_at as latest_build_canceled_at, + latest_build.error as latest_build_error, + latest_build.transition as latest_build_transition FROM workspaces JOIN @@ -266,23 +271,73 @@ WHERE END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter -ORDER BY - -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN workspaces.owner_id = @requester_id AND workspaces.favorite THEN 0 ELSE 1 END ASC, - (latest_build.completed_at IS NOT NULL AND - latest_build.canceled_at IS NULL AND - latest_build.error IS NULL AND - latest_build.transition = 'start'::workspace_transition) DESC, - LOWER(users.username) ASC, - LOWER(workspaces.name) ASC -LIMIT - CASE - WHEN @limit_ :: integer > 0 THEN - @limit_ - END -OFFSET - @offset_ -; +), filtered_workspaces_order AS ( + SELECT + fw.* + FROM + filtered_workspaces fw + ORDER BY + -- To ensure that 'favorite' workspaces show up first in the list only for their owner. + CASE WHEN owner_id = @requester_id AND favorite THEN 0 ELSE 1 END ASC, + (latest_build_completed_at IS NOT NULL AND + latest_build_canceled_at IS NULL AND + latest_build_error IS NULL AND + latest_build_transition = 'start'::workspace_transition) DESC, + LOWER(username) ASC, + LOWER(name) ASC + LIMIT + CASE + WHEN @limit_ :: integer > 0 THEN + @limit_ + END + OFFSET + @offset_ +), filtered_workspaces_order_with_summary AS ( + SELECT + fwo.* + FROM + filtered_workspaces_order fwo + -- Always return a technical summary row with total count of workspaces. + -- It is used to present the correct count if pagination goes beyond the offset. + UNION ALL + SELECT + '00000000-0000-0000-0000-000000000000'::uuid, -- id + '0001-01-01 00:00:00+00'::timestamp, -- created_at + '0001-01-01 00:00:00+00'::timestamp, -- updated_at + '00000000-0000-0000-0000-000000000000'::uuid, -- owner_id + '00000000-0000-0000-0000-000000000000'::uuid, -- organization_id + '00000000-0000-0000-0000-000000000000'::uuid, -- template_id + false, -- deleted + '**TECHNICAL_ROW**', -- name + '', -- autostart_schedule + 0, -- ttl + '0001-01-01 00:00:00+00'::timestamp, -- last_used_at + '0001-01-01 00:00:00+00'::timestamp, -- dormant_at + '0001-01-01 00:00:00+00'::timestamp, -- deleting_at + 'never'::automatic_updates, -- automatic_updates + false, -- favorite + -- Extra columns added to `filtered_workspaces` + '', -- template_name + '00000000-0000-0000-0000-000000000000'::uuid, -- template_version_id + '', -- template_version_name + '', -- username + '0001-01-01 00:00:00+00'::timestamp, -- latest_build_completed_at, + '0001-01-01 00:00:00+00'::timestamp, -- latest_build_canceled_at, + '', -- latest_build_error + 'start'::workspace_transition -- latest_build_transition +), total_count AS ( + SELECT + count(*) AS count + FROM + filtered_workspaces +) +SELECT + fwos.*, + tc.count +FROM + filtered_workspaces_order_with_summary fwos +CROSS JOIN + total_count tc; -- name: GetWorkspaceByOwnerIDAndName :one SELECT diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0ab1e5ee41660..baf337609d2f5 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -182,12 +182,21 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { return } if len(workspaceRows) == 0 { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspaces.", + Detail: "Workspace summary row is missing.", + }) + return + } + if len(workspaceRows) == 1 { httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{ Workspaces: []codersdk.Workspace{}, - Count: 0, + Count: int(workspaceRows[0].Count), }) return } + // Skip technical summary row + workspaceRows = workspaceRows[:len(workspaceRows)-1] workspaces := database.ConvertWorkspaceRows(workspaceRows)