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 +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy