diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 76d78754255ca..630e5e6165c6c 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2789,6 +2789,14 @@ func (q *querier) GetWorkspaces(ctx context.Context, arg database.GetWorkspacesP return q.db.GetAuthorizedWorkspaces(ctx, arg, prep) } +func (q *querier) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { + prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + return nil, xerrors.Errorf("(dev error) prepare sql filter: %w", err) + } + return q.db.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, ownerID, prep) +} + func (q *querier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.WorkspaceTable, error) { return q.db.GetWorkspacesEligibleForTransition(ctx, now) } @@ -4242,6 +4250,10 @@ func (q *querier) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetW return q.GetWorkspaces(ctx, arg) } +func (q *querier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID, _ rbac.PreparedAuthorized) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { + return q.GetWorkspacesAndAgentsByOwnerID(ctx, ownerID) +} + // 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/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 73403a95b7859..515330f2edefb 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1470,6 +1470,24 @@ func (s *MethodTestSuite) TestWorkspace() { // No asserts here because SQLFilter. check.Args(database.GetWorkspacesParams{}, emptyPreparedAuthorized{}).Asserts() })) + s.Run("GetWorkspacesAndAgentsByOwnerID", s.Subtest(func(db database.Store, check *expects) { + ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + _ = dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) + _ = dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + // No asserts here because SQLFilter. + check.Args(ws.OwnerID).Asserts() + })) + s.Run("GetAuthorizedWorkspacesAndAgentsByOwnerID", s.Subtest(func(db database.Store, check *expects) { + ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) + build := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID, JobID: uuid.New()}) + _ = dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{ID: build.JobID, Type: database.ProvisionerJobTypeWorkspaceBuild}) + res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: build.JobID}) + _ = dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) + // No asserts here because SQLFilter. + check.Args(ws.OwnerID, emptyPreparedAuthorized{}).Asserts() + })) s.Run("GetLatestWorkspaceBuildByWorkspaceID", s.Subtest(func(db database.Store, check *expects) { ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) b := dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{WorkspaceID: ws.ID}) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index e949b5be4880d..8214a9f6b77ff 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -6839,6 +6839,11 @@ func (q *FakeQuerier) GetWorkspaces(ctx context.Context, arg database.GetWorkspa return workspaceRows, err } +func (q *FakeQuerier) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { + // No auth filter. + return q.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, ownerID, nil) +} + func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.WorkspaceTable, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -11224,6 +11229,67 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. return q.convertToWorkspaceRowsNoLock(ctx, workspaces, int64(beforePageCount), arg.WithSummary), nil } +func (q *FakeQuerier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if prepared != nil { + // Call this to match the same function calls as the SQL implementation. + _, err := prepared.CompileToSQL(ctx, rbac.ConfigWithoutACL()) + if err != nil { + return nil, err + } + } + workspaces := make([]database.WorkspaceTable, 0) + for _, workspace := range q.workspaces { + if workspace.OwnerID == ownerID && !workspace.Deleted { + workspaces = append(workspaces, workspace) + } + } + + out := make([]database.GetWorkspacesAndAgentsByOwnerIDRow, 0, len(workspaces)) + for _, w := range workspaces { + // these always exist + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, w.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) + } + + outAgents := make([]database.AgentIDNamePair, 0) + resources, err := q.getWorkspaceResourcesByJobIDNoLock(ctx, job.ID) + if err != nil { + return nil, xerrors.Errorf("get workspace resources: %w", err) + } + if len(resources) > 0 { + agents, err := q.getWorkspaceAgentsByResourceIDsNoLock(ctx, []uuid.UUID{resources[0].ID}) + if err != nil { + return nil, xerrors.Errorf("get workspace agents: %w", err) + } + for _, a := range agents { + outAgents = append(outAgents, database.AgentIDNamePair{ + ID: a.ID, + Name: a.Name, + }) + } + } + + out = append(out, database.GetWorkspacesAndAgentsByOwnerIDRow{ + ID: w.ID, + Name: w.Name, + JobStatus: job.JobStatus, + Transition: build.Transition, + Agents: outAgents, + }) + } + + return out, nil +} + func (q *FakeQuerier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { if err := validateDatabaseType(arg); err != nil { return nil, err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 7e74aab3b9de0..2d542be1160fd 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1645,6 +1645,13 @@ func (m queryMetricsStore) GetWorkspaces(ctx context.Context, arg database.GetWo return workspaces, err } +func (m queryMetricsStore) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspacesAndAgentsByOwnerID(ctx, ownerID) + m.queryLatencies.WithLabelValues("GetWorkspacesAndAgentsByOwnerID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]database.WorkspaceTable, error) { start := time.Now() workspaces, err := m.s.GetWorkspacesEligibleForTransition(ctx, now) @@ -2695,6 +2702,13 @@ func (m queryMetricsStore) GetAuthorizedWorkspaces(ctx context.Context, arg data return workspaces, err } +func (m queryMetricsStore) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID, prepared rbac.PreparedAuthorized) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx, ownerID, prepared) + m.queryLatencies.WithLabelValues("GetAuthorizedWorkspacesAndAgentsByOwnerID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) 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 ffc9ab79f777e..39e82f2e82df5 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1057,6 +1057,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspaces(arg0, arg1, arg2 any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspaces", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspaces), arg0, arg1, arg2) } +// GetAuthorizedWorkspacesAndAgentsByOwnerID mocks base method. +func (m *MockStore) GetAuthorizedWorkspacesAndAgentsByOwnerID(arg0 context.Context, arg1 uuid.UUID, arg2 rbac.PreparedAuthorized) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAuthorizedWorkspacesAndAgentsByOwnerID", arg0, arg1, arg2) + ret0, _ := ret[0].([]database.GetWorkspacesAndAgentsByOwnerIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAuthorizedWorkspacesAndAgentsByOwnerID indicates an expected call of GetAuthorizedWorkspacesAndAgentsByOwnerID. +func (mr *MockStoreMockRecorder) GetAuthorizedWorkspacesAndAgentsByOwnerID(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspacesAndAgentsByOwnerID), arg0, arg1, arg2) +} + // GetCoordinatorResumeTokenSigningKey mocks base method. func (m *MockStore) GetCoordinatorResumeTokenSigningKey(arg0 context.Context) (string, error) { m.ctrl.T.Helper() @@ -3472,6 +3487,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaces(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaces", reflect.TypeOf((*MockStore)(nil).GetWorkspaces), arg0, arg1) } +// GetWorkspacesAndAgentsByOwnerID mocks base method. +func (m *MockStore) GetWorkspacesAndAgentsByOwnerID(arg0 context.Context, arg1 uuid.UUID) ([]database.GetWorkspacesAndAgentsByOwnerIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspacesAndAgentsByOwnerID", arg0, arg1) + ret0, _ := ret[0].([]database.GetWorkspacesAndAgentsByOwnerIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspacesAndAgentsByOwnerID indicates an expected call of GetWorkspacesAndAgentsByOwnerID. +func (mr *MockStoreMockRecorder) GetWorkspacesAndAgentsByOwnerID(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspacesAndAgentsByOwnerID", reflect.TypeOf((*MockStore)(nil).GetWorkspacesAndAgentsByOwnerID), arg0, arg1) +} + // GetWorkspacesEligibleForTransition mocks base method. func (m *MockStore) GetWorkspacesEligibleForTransition(arg0 context.Context, arg1 time.Time) ([]database.WorkspaceTable, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e4e119423ea78..557b5c2dd9325 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1,5 +1,10 @@ -- Code generated by 'make coderd/database/generate'. DO NOT EDIT. +CREATE TYPE agent_id_name_pair AS ( + id uuid, + name text +); + CREATE TYPE api_key_scope AS ENUM ( 'all', 'application_connect' diff --git a/coderd/database/migrations/000273_workspace_updates.down.sql b/coderd/database/migrations/000273_workspace_updates.down.sql new file mode 100644 index 0000000000000..b7c80319a06b1 --- /dev/null +++ b/coderd/database/migrations/000273_workspace_updates.down.sql @@ -0,0 +1 @@ +DROP TYPE agent_id_name_pair; diff --git a/coderd/database/migrations/000273_workspace_updates.up.sql b/coderd/database/migrations/000273_workspace_updates.up.sql new file mode 100644 index 0000000000000..bca44908cc71e --- /dev/null +++ b/coderd/database/migrations/000273_workspace_updates.up.sql @@ -0,0 +1,4 @@ +CREATE TYPE agent_id_name_pair AS ( + id uuid, + name text +); diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 9cab04d8e5c2e..e687994778017 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -221,6 +221,7 @@ func (q *sqlQuerier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([ type workspaceQuerier interface { GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]GetWorkspacesRow, error) + GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID, prepared rbac.PreparedAuthorized) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) } // GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access. @@ -320,6 +321,49 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa return items, nil } +func (q *sqlQuerier) GetAuthorizedWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID, prepared rbac.PreparedAuthorized) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) { + authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigWorkspaces()) + if err != nil { + return nil, xerrors.Errorf("compile authorized filter: %w", err) + } + + // 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(getWorkspacesAndAgentsByOwnerID, fmt.Sprintf(" AND %s", authorizedFilter)) + if err != nil { + return nil, xerrors.Errorf("insert authorized filter: %w", err) + } + + // The name comment is for metric tracking + query := fmt.Sprintf("-- name: GetAuthorizedWorkspacesAndAgentsByOwnerID :many\n%s", filtered) + rows, err := q.db.QueryContext(ctx, query, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetWorkspacesAndAgentsByOwnerIDRow + for rows.Next() { + var i GetWorkspacesAndAgentsByOwnerIDRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.JobStatus, + &i.Transition, + pq.Array(&i.Agents), + ); 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 +} + 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 fcb58a7d6e305..46d1b1ae5b322 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -345,6 +345,7 @@ type sqlcQuerier interface { // It has to be a CTE because the set returning function 'unnest' cannot // be used in a WHERE clause. GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) + GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]WorkspaceTable, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) // We use the organization_id as the id diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 58c9626f2c9bf..41fca8d0a453e 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -24,7 +24,9 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/migrations" + "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/testutil" ) @@ -612,6 +614,130 @@ func TestGetWorkspaceAgentUsageStatsAndLabels(t *testing.T) { }) } +func TestGetAuthorizedWorkspacesAndAgentsByOwnerID(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + + ctx := testutil.Context(t, testutil.WaitLong) + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + authorizer := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()) + + org := dbgen.Organization(t, db, database.Organization{}) + owner := dbgen.User(t, db, database.User{ + RBACRoles: []string{rbac.RoleOwner().String()}, + }) + user := dbgen.User(t, db, database.User{}) + tpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: owner.ID, + }) + + pendingID := uuid.New() + createTemplateVersion(t, db, tpl, tvArgs{ + Status: database.ProvisionerJobStatusPending, + CreateWorkspace: true, + WorkspaceID: pendingID, + CreateAgent: true, + }) + failedID := uuid.New() + createTemplateVersion(t, db, tpl, tvArgs{ + Status: database.ProvisionerJobStatusFailed, + CreateWorkspace: true, + CreateAgent: true, + WorkspaceID: failedID, + }) + succeededID := uuid.New() + createTemplateVersion(t, db, tpl, tvArgs{ + Status: database.ProvisionerJobStatusSucceeded, + WorkspaceTransition: database.WorkspaceTransitionStart, + CreateWorkspace: true, + WorkspaceID: succeededID, + CreateAgent: true, + ExtraAgents: 1, + ExtraBuilds: 2, + }) + deletedID := uuid.New() + createTemplateVersion(t, db, tpl, tvArgs{ + Status: database.ProvisionerJobStatusSucceeded, + WorkspaceTransition: database.WorkspaceTransitionDelete, + CreateWorkspace: true, + WorkspaceID: deletedID, + CreateAgent: false, + }) + + ownerCheckFn := func(ownerRows []database.GetWorkspacesAndAgentsByOwnerIDRow) { + require.Len(t, ownerRows, 4) + for _, row := range ownerRows { + switch row.ID { + case pendingID: + require.Len(t, row.Agents, 1) + require.Equal(t, database.ProvisionerJobStatusPending, row.JobStatus) + case failedID: + require.Len(t, row.Agents, 1) + require.Equal(t, database.ProvisionerJobStatusFailed, row.JobStatus) + case succeededID: + require.Len(t, row.Agents, 2) + require.Equal(t, database.ProvisionerJobStatusSucceeded, row.JobStatus) + require.Equal(t, database.WorkspaceTransitionStart, row.Transition) + case deletedID: + require.Len(t, row.Agents, 0) + require.Equal(t, database.ProvisionerJobStatusSucceeded, row.JobStatus) + require.Equal(t, database.WorkspaceTransitionDelete, row.Transition) + default: + t.Fatalf("unexpected workspace ID: %s", row.ID) + } + } + } + t.Run("sqlQuerier", func(t *testing.T) { + t.Parallel() + + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.ID, rbac.ExpandableScope(rbac.ScopeAll)) + require.NoError(t, err) + preparedUser, err := authorizer.Prepare(ctx, userSubject, policy.ActionRead, rbac.ResourceWorkspace.Type) + require.NoError(t, err) + userCtx := dbauthz.As(ctx, userSubject) + userRows, err := db.GetAuthorizedWorkspacesAndAgentsByOwnerID(userCtx, owner.ID, preparedUser) + require.NoError(t, err) + require.Len(t, userRows, 0) + + ownerSubject, _, err := httpmw.UserRBACSubject(ctx, db, owner.ID, rbac.ExpandableScope(rbac.ScopeAll)) + require.NoError(t, err) + preparedOwner, err := authorizer.Prepare(ctx, ownerSubject, policy.ActionRead, rbac.ResourceWorkspace.Type) + require.NoError(t, err) + ownerCtx := dbauthz.As(ctx, ownerSubject) + ownerRows, err := db.GetAuthorizedWorkspacesAndAgentsByOwnerID(ownerCtx, owner.ID, preparedOwner) + require.NoError(t, err) + ownerCheckFn(ownerRows) + }) + + t.Run("dbauthz", func(t *testing.T) { + t.Parallel() + + authzdb := dbauthz.New(db, authorizer, slogtest.Make(t, &slogtest.Options{}), coderdtest.AccessControlStorePointer()) + + userSubject, _, err := httpmw.UserRBACSubject(ctx, authzdb, user.ID, rbac.ExpandableScope(rbac.ScopeAll)) + require.NoError(t, err) + userCtx := dbauthz.As(ctx, userSubject) + + ownerSubject, _, err := httpmw.UserRBACSubject(ctx, authzdb, owner.ID, rbac.ExpandableScope(rbac.ScopeAll)) + require.NoError(t, err) + ownerCtx := dbauthz.As(ctx, ownerSubject) + + userRows, err := authzdb.GetWorkspacesAndAgentsByOwnerID(userCtx, owner.ID) + require.NoError(t, err) + require.Len(t, userRows, 0) + + ownerRows, err := authzdb.GetWorkspacesAndAgentsByOwnerID(ownerCtx, owner.ID) + require.NoError(t, err) + ownerCheckFn(ownerRows) + }) +} + func TestInsertWorkspaceAgentLogs(t *testing.T) { t.Parallel() if testing.Short() { @@ -1537,7 +1663,11 @@ type tvArgs struct { Status database.ProvisionerJobStatus // CreateWorkspace is true if we should create a workspace for the template version CreateWorkspace bool + WorkspaceID uuid.UUID + CreateAgent bool WorkspaceTransition database.WorkspaceTransition + ExtraAgents int + ExtraBuilds int } // createTemplateVersion is a helper function to create a version with its dependencies. @@ -1554,49 +1684,18 @@ func createTemplateVersion(t testing.TB, db database.Store, tpl database.Templat CreatedBy: tpl.CreatedBy, }) - earlier := sql.NullTime{ - Time: dbtime.Now().Add(time.Second * -30), - Valid: true, - } - now := sql.NullTime{ - Time: dbtime.Now(), - Valid: true, - } - j := database.ProvisionerJob{ + latestJob := database.ProvisionerJob{ ID: version.JobID, - CreatedAt: earlier.Time, - UpdatedAt: earlier.Time, Error: sql.NullString{}, OrganizationID: tpl.OrganizationID, InitiatorID: tpl.CreatedBy, Type: database.ProvisionerJobTypeTemplateVersionImport, } - - switch args.Status { - case database.ProvisionerJobStatusRunning: - j.StartedAt = earlier - case database.ProvisionerJobStatusPending: - case database.ProvisionerJobStatusFailed: - j.StartedAt = earlier - j.CompletedAt = now - j.Error = sql.NullString{ - String: "failed", - Valid: true, - } - j.ErrorCode = sql.NullString{ - String: "failed", - Valid: true, - } - case database.ProvisionerJobStatusSucceeded: - j.StartedAt = earlier - j.CompletedAt = now - default: - t.Fatalf("invalid status: %s", args.Status) - } - - dbgen.ProvisionerJob(t, db, nil, j) + setJobStatus(t, args.Status, &latestJob) + dbgen.ProvisionerJob(t, db, nil, latestJob) if args.CreateWorkspace { wrk := dbgen.Workspace(t, db, database.WorkspaceTable{ + ID: args.WorkspaceID, CreatedAt: time.Time{}, UpdatedAt: time.Time{}, OwnerID: tpl.CreatedBy, @@ -1607,11 +1706,15 @@ func createTemplateVersion(t testing.TB, db database.Store, tpl database.Templat if args.WorkspaceTransition != "" { trans = args.WorkspaceTransition } - buildJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + latestJob = database.ProvisionerJob{ Type: database.ProvisionerJobTypeWorkspaceBuild, - CompletedAt: now, InitiatorID: tpl.CreatedBy, OrganizationID: tpl.OrganizationID, + } + setJobStatus(t, args.Status, &latestJob) + latestJob = dbgen.ProvisionerJob(t, db, nil, latestJob) + latestResource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: latestJob.ID, }) dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ WorkspaceID: wrk.ID, @@ -1619,12 +1722,77 @@ func createTemplateVersion(t testing.TB, db database.Store, tpl database.Templat BuildNumber: 1, Transition: trans, InitiatorID: tpl.CreatedBy, - JobID: buildJob.ID, + JobID: latestJob.ID, }) + for i := 0; i < args.ExtraBuilds; i++ { + latestJob = database.ProvisionerJob{ + Type: database.ProvisionerJobTypeWorkspaceBuild, + InitiatorID: tpl.CreatedBy, + OrganizationID: tpl.OrganizationID, + } + setJobStatus(t, args.Status, &latestJob) + latestJob = dbgen.ProvisionerJob(t, db, nil, latestJob) + latestResource = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: latestJob.ID, + }) + dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: wrk.ID, + TemplateVersionID: version.ID, + BuildNumber: int32(i) + 2, + Transition: trans, + InitiatorID: tpl.CreatedBy, + JobID: latestJob.ID, + }) + } + + if args.CreateAgent { + dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: latestResource.ID, + }) + } + for i := 0; i < args.ExtraAgents; i++ { + dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + ResourceID: latestResource.ID, + }) + } } return version } +func setJobStatus(t testing.TB, status database.ProvisionerJobStatus, j *database.ProvisionerJob) { + t.Helper() + + earlier := sql.NullTime{ + Time: dbtime.Now().Add(time.Second * -30), + Valid: true, + } + now := sql.NullTime{ + Time: dbtime.Now(), + Valid: true, + } + switch status { + case database.ProvisionerJobStatusRunning: + j.StartedAt = earlier + case database.ProvisionerJobStatusPending: + case database.ProvisionerJobStatusFailed: + j.StartedAt = earlier + j.CompletedAt = now + j.Error = sql.NullString{ + String: "failed", + Valid: true, + } + j.ErrorCode = sql.NullString{ + String: "failed", + Valid: true, + } + case database.ProvisionerJobStatusSucceeded: + j.StartedAt = earlier + j.CompletedAt = now + default: + t.Fatalf("invalid status: %s", status) + } +} + func TestArchiveVersions(t *testing.T) { t.Parallel() if testing.Short() { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 46928ae1d3738..e72db60f3b051 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15274,6 +15274,81 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) return items, nil } +const getWorkspacesAndAgentsByOwnerID = `-- name: GetWorkspacesAndAgentsByOwnerID :many +SELECT + workspaces.id as id, + workspaces.name as name, + job_status, + transition, + (array_agg(ROW(agent_id, agent_name)::agent_id_name_pair) FILTER (WHERE agent_id IS NOT NULL))::agent_id_name_pair[] as agents +FROM workspaces +LEFT JOIN LATERAL ( + SELECT + workspace_id, + job_id, + transition, + job_status + FROM workspace_builds + JOIN provisioner_jobs ON provisioner_jobs.id = workspace_builds.job_id + WHERE workspace_builds.workspace_id = workspaces.id + ORDER BY build_number DESC + LIMIT 1 +) latest_build ON true +LEFT JOIN LATERAL ( + SELECT + workspace_agents.id as agent_id, + workspace_agents.name as agent_name, + job_id + FROM workspace_resources + JOIN workspace_agents ON workspace_agents.resource_id = workspace_resources.id + WHERE job_id = latest_build.job_id +) resources ON true +WHERE + -- Filter by owner_id + workspaces.owner_id = $1 :: uuid + AND workspaces.deleted = false + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspacesAndAgentsByOwnerID + -- @authorize_filter +GROUP BY workspaces.id, workspaces.name, latest_build.job_status, latest_build.job_id, latest_build.transition +` + +type GetWorkspacesAndAgentsByOwnerIDRow struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + JobStatus ProvisionerJobStatus `db:"job_status" json:"job_status"` + Transition WorkspaceTransition `db:"transition" json:"transition"` + Agents []AgentIDNamePair `db:"agents" json:"agents"` +} + +func (q *sqlQuerier) GetWorkspacesAndAgentsByOwnerID(ctx context.Context, ownerID uuid.UUID) ([]GetWorkspacesAndAgentsByOwnerIDRow, error) { + rows, err := q.db.QueryContext(ctx, getWorkspacesAndAgentsByOwnerID, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetWorkspacesAndAgentsByOwnerIDRow + for rows.Next() { + var i GetWorkspacesAndAgentsByOwnerIDRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.JobStatus, + &i.Transition, + pq.Array(&i.Agents), + ); 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 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 diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 369333a5eab9d..a1f41eb84d603 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -690,3 +690,42 @@ UPDATE workspaces SET favorite = true WHERE id = @id; -- name: UnfavoriteWorkspace :exec UPDATE workspaces SET favorite = false WHERE id = @id; + +-- name: GetWorkspacesAndAgentsByOwnerID :many +SELECT + workspaces.id as id, + workspaces.name as name, + job_status, + transition, + (array_agg(ROW(agent_id, agent_name)::agent_id_name_pair) FILTER (WHERE agent_id IS NOT NULL))::agent_id_name_pair[] as agents +FROM workspaces +LEFT JOIN LATERAL ( + SELECT + workspace_id, + job_id, + transition, + job_status + FROM workspace_builds + JOIN provisioner_jobs ON provisioner_jobs.id = workspace_builds.job_id + WHERE workspace_builds.workspace_id = workspaces.id + ORDER BY build_number DESC + LIMIT 1 +) latest_build ON true +LEFT JOIN LATERAL ( + SELECT + workspace_agents.id as agent_id, + workspace_agents.name as agent_name, + job_id + FROM workspace_resources + JOIN workspace_agents ON workspace_agents.resource_id = workspace_resources.id + WHERE job_id = latest_build.job_id +) resources ON true +WHERE + -- Filter by owner_id + workspaces.owner_id = @owner_id :: uuid + AND workspaces.deleted = false + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspacesAndAgentsByOwnerID + -- @authorize_filter +GROUP BY workspaces.id, workspaces.name, latest_build.job_status, latest_build.job_id, latest_build.transition; + + diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 257c95ddb2d7a..2161feb47e1c3 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -28,6 +28,9 @@ sql: emit_enum_valid_method: true emit_all_enum_values: true overrides: + - db_type: "agent_id_name_pair" + go_type: + type: "AgentIDNamePair" # Used in 'CustomRoles' query to filter by (name,organization_id) - db_type: "name_organization_pair" go_type: diff --git a/coderd/database/types.go b/coderd/database/types.go index f6cf87db14ec7..8e22258382abb 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -4,6 +4,7 @@ import ( "database/sql/driver" "encoding/json" "fmt" + "strings" "time" "github.com/google/uuid" @@ -174,3 +175,35 @@ func (*NameOrganizationPair) Scan(_ interface{}) error { func (a NameOrganizationPair) Value() (driver.Value, error) { return fmt.Sprintf(`(%s,%s)`, a.Name, a.OrganizationID.String()), nil } + +// AgentIDNamePair is used as a result tuple for workspace and agent rows. +type AgentIDNamePair struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` +} + +func (p *AgentIDNamePair) Scan(src interface{}) error { + var v string + switch a := src.(type) { + case []byte: + v = string(a) + case string: + v = a + default: + return xerrors.Errorf("unexpected type %T", src) + } + parts := strings.Split(strings.Trim(v, "()"), ",") + if len(parts) != 2 { + return xerrors.New("invalid format for AgentIDNamePair") + } + id, err := uuid.Parse(strings.TrimSpace(parts[0])) + if err != nil { + return err + } + p.ID, p.Name = id, strings.TrimSpace(parts[1]) + return nil +} + +func (p AgentIDNamePair) Value() (driver.Value, error) { + return fmt.Sprintf(`(%s,%s)`, p.ID.String(), p.Name), nil +}