mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: add db query to retrieve workspaces & their agents (#14792)
Second PR for #14716. Adds a query that, given a user ID, returns all the workspaces they own, that can also be `ActionRead` by the requesting user. ``` type GetWorkspacesAndAgentsByOwnerIDRow struct { WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` WorkspaceName string `db:"workspace_name" json:"workspace_name"` JobStatus ProvisionerJobStatus `db:"job_status" json:"job_status"` Transition WorkspaceTransition `db:"transition" json:"transition"` Agents []AgentIDNamePair `db:"agents" json:"agents"` } ``` `JobStatus` and `Transition` are set using the latest build/job of the workspace. Deleted workspaces are not included.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+5
@@ -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'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TYPE agent_id_name_pair;
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TYPE agent_id_name_pair AS (
|
||||
id uuid,
|
||||
name text
|
||||
);
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+210
-42
@@ -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,6 +1684,84 @@ func createTemplateVersion(t testing.TB, db database.Store, tpl database.Templat
|
||||
CreatedBy: tpl.CreatedBy,
|
||||
})
|
||||
|
||||
latestJob := database.ProvisionerJob{
|
||||
ID: version.JobID,
|
||||
Error: sql.NullString{},
|
||||
OrganizationID: tpl.OrganizationID,
|
||||
InitiatorID: tpl.CreatedBy,
|
||||
Type: database.ProvisionerJobTypeTemplateVersionImport,
|
||||
}
|
||||
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,
|
||||
OrganizationID: tpl.OrganizationID,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
trans := database.WorkspaceTransitionStart
|
||||
if args.WorkspaceTransition != "" {
|
||||
trans = args.WorkspaceTransition
|
||||
}
|
||||
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: 1,
|
||||
Transition: trans,
|
||||
InitiatorID: tpl.CreatedBy,
|
||||
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,
|
||||
@@ -1562,17 +1770,7 @@ func createTemplateVersion(t testing.TB, db database.Store, tpl database.Templat
|
||||
Time: dbtime.Now(),
|
||||
Valid: true,
|
||||
}
|
||||
j := 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 {
|
||||
switch status {
|
||||
case database.ProvisionerJobStatusRunning:
|
||||
j.StartedAt = earlier
|
||||
case database.ProvisionerJobStatusPending:
|
||||
@@ -1591,38 +1789,8 @@ func createTemplateVersion(t testing.TB, db database.Store, tpl database.Templat
|
||||
j.StartedAt = earlier
|
||||
j.CompletedAt = now
|
||||
default:
|
||||
t.Fatalf("invalid status: %s", args.Status)
|
||||
t.Fatalf("invalid status: %s", status)
|
||||
}
|
||||
|
||||
dbgen.ProvisionerJob(t, db, nil, j)
|
||||
if args.CreateWorkspace {
|
||||
wrk := dbgen.Workspace(t, db, database.WorkspaceTable{
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
OwnerID: tpl.CreatedBy,
|
||||
OrganizationID: tpl.OrganizationID,
|
||||
TemplateID: tpl.ID,
|
||||
})
|
||||
trans := database.WorkspaceTransitionStart
|
||||
if args.WorkspaceTransition != "" {
|
||||
trans = args.WorkspaceTransition
|
||||
}
|
||||
buildJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
||||
CompletedAt: now,
|
||||
InitiatorID: tpl.CreatedBy,
|
||||
OrganizationID: tpl.OrganizationID,
|
||||
})
|
||||
dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
||||
WorkspaceID: wrk.ID,
|
||||
TemplateVersionID: version.ID,
|
||||
BuildNumber: 1,
|
||||
Transition: trans,
|
||||
InitiatorID: tpl.CreatedBy,
|
||||
JobID: buildJob.ID,
|
||||
})
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func TestArchiveVersions(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user