diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4b281f074c8..a4c5e8500d8 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -828,6 +828,13 @@ var ( // Organization reads support organization-scoped model // resolution and user-global settings in the default org. rbac.ResourceOrganization.Type: {policy.ActionRead}, + // A workspace's RBAC object is workspace_dormant while + // dormant_at is set, and dormancy auto-delete leaves it set + // on the soft-deleted row. Mirror the workspace grant so + // chatd can tell a dormant or deleted chat workspace apart + // from a permission failure and heartbeat activity bumps + // keep working. Starting one runs as the owner. + rbac.ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionUpdate}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 9f87cfec008..fb80b3f473f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7841,8 +7841,19 @@ func TestAsChatd(t *testing.T) { require.NoError(t, err, "workspace %s should be allowed", action) } + // Dormant (including dormancy-deleted) chat workspaces get the + // same grant so tools report their state instead of a permission + // failure and activity bumps keep working. + var err error + for _, action := range []policy.Action{ + policy.ActionRead, policy.ActionUpdate, + } { + err = auth.Authorize(ctx, actor, action, rbac.ResourceWorkspaceDormant) + require.NoError(t, err, "dormant workspace %s should be allowed", action) + } + // DeploymentConfig reads are allowed, but writes are not. - err := auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceDeploymentConfig) + err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceDeploymentConfig) require.NoError(t, err, "deployment config read should be allowed") err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig) require.Error(t, err, "deployment config update should not be allowed") @@ -7874,6 +7885,15 @@ func TestAsChatd(t *testing.T) { err := auth.Authorize(ctx, actor, policy.ActionDelete, rbac.ResourceWorkspace) require.Error(t, err, "workspace delete should be denied") + // Dormant workspaces share the workspace grant only; lifecycle + // transitions run under the owner actor. + for _, action := range []policy.Action{ + policy.ActionDelete, policy.ActionWorkspaceStop, policy.ActionCreate, + } { + err = auth.Authorize(ctx, actor, action, rbac.ResourceWorkspaceDormant) + require.Error(t, err, "dormant workspace %s should be denied", action) + } + // Cannot access users. err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceUser) require.Error(t, err, "user read should be denied") diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index ee17c598257..65eec37b27c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -120,6 +120,7 @@ const ( var ( errChatHasNoWorkspaceAgent = xerrors.New("workspace has no running agent: the workspace is likely stopped. Use the start_workspace tool to start it") + errChatWorkspaceDeleted = xerrors.New("the chat's workspace was deleted (for example by dormancy cleanup) and cannot execute tools. Use the create_workspace tool to create a new one") errChatAgentDisconnected = xerrors.New( "workspace agent has been disconnected for at least 90 seconds " + "and cannot execute tools. To recover, call stop_workspace " + @@ -626,6 +627,25 @@ func (c *turnWorkspaceContext) loadWorkspaceAgentLocked( return chatSnapshot, database.WorkspaceAgent{}, xerrors.New("no workspace is associated with this chat. Use the create_workspace tool to create one") } + // A soft-deleted workspace keeps its agent rows, so the bound agent + // would otherwise be dialed and fail as merely unreachable. + ws, err := c.server.db.GetWorkspaceByID(ctx, chatSnapshot.WorkspaceID.UUID) + if err != nil { + return chatSnapshot, database.WorkspaceAgent{}, xerrors.Errorf( + "load workspace: %w. %s", err, chattool.WorkspaceUnavailableHint, + ) + } + if ws.Deleted { + // A concurrent create_workspace may have rebound the chat + // while the row was read; resolve the replacement instead. + latestChat, workspaceMatches := c.currentWorkspaceMatches(chatSnapshot.WorkspaceID) + if !workspaceMatches { + chatSnapshot = latestChat + continue + } + return chatSnapshot, database.WorkspaceAgent{}, errChatWorkspaceDeleted + } + if chatSnapshot.AgentID.Valid { agent, err := c.server.db.GetWorkspaceAgentByID(ctx, chatSnapshot.AgentID.UUID) if err == nil { diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 186977f4a7e..e8674cb284e 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -1187,6 +1187,7 @@ func TestTurnWorkspaceContext_BindingFirstPath(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -1232,6 +1233,7 @@ func TestTurnWorkspaceContext_NullBindingLazyBind(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) buildID := uuid.New() agentID := uuid.New() chat := database.Chat{ @@ -1300,6 +1302,7 @@ func TestTurnWorkspaceContext_StaleBindingRepair(t *testing.T) { expectBestEffortContextRepin(db) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) staleAgentID := uuid.New() buildID := uuid.New() currentAgentID := uuid.New() @@ -1356,6 +1359,7 @@ func TestTurnWorkspaceContextGetWorkspaceConnLazyValidationSwitchesWorkspaceAgen expectBestEffortContextRepin(db) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) staleAgentID := uuid.New() currentAgentID := uuid.New() buildID := uuid.New() @@ -1435,6 +1439,7 @@ func TestTurnWorkspaceContextGetWorkspaceConnFastFailsWithoutCurrentAgent(t *tes db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) staleAgentID := uuid.New() resourceID := uuid.New() chat := database.Chat{ @@ -1498,6 +1503,121 @@ func TestTurnWorkspaceContextGetWorkspaceConnFastFailsWithoutCurrentAgent(t *tes require.Equal(t, uuid.NullUUID{}, workspaceCtx.cachedWorkspaceID) } +func TestTurnWorkspaceContextGetWorkspaceConnDeletedWorkspace(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + workspaceID := uuid.New() + staleAgentID := uuid.New() + chat := database.Chat{ + ID: uuid.New(), + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + AgentID: uuid.NullUUID{UUID: staleAgentID, Valid: true}, + } + + // Dormancy auto-delete soft-deletes the row but keeps dormant_at and + // the agent rows, so the bound agent must not be dialed. + db.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID). + Return(database.Workspace{ + ID: workspaceID, + Deleted: true, + DormantAt: sql.NullTime{Time: time.Now(), Valid: true}, + }, nil). + Times(1) + + server := &Server{ + db: db, + clock: quartz.NewReal(), + dialTimeout: 30 * time.Second, + } + server.agentConnFn = func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { + t.Fatal("agentConnFn must not be called for a deleted workspace") + return nil, nil, nil + } + + chatStateMu := &sync.Mutex{} + currentChat := chat + workspaceCtx := turnWorkspaceContext{ + server: server, + chatStateMu: chatStateMu, + currentChat: ¤tChat, + loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil }, + } + defer workspaceCtx.close() + + gotConn, err := workspaceCtx.getWorkspaceConn(ctx) + require.Nil(t, gotConn) + require.ErrorIs(t, err, errChatWorkspaceDeleted) + require.Contains(t, err.Error(), "create_workspace") + require.NotContains(t, err.Error(), "forbidden") + + workspaceCtx.mu.Lock() + defer workspaceCtx.mu.Unlock() + require.False(t, workspaceCtx.agentLoaded) + require.Nil(t, workspaceCtx.conn) +} + +func TestTurnWorkspaceContextEnsureWorkspaceAgentRebindsFromDeletedWorkspace(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + deletedWorkspaceID := uuid.New() + replacementWorkspaceID := uuid.New() + buildID := uuid.New() + replacementAgent := database.WorkspaceAgent{ID: uuid.New()} + chat := database.Chat{ + ID: uuid.New(), + WorkspaceID: uuid.NullUUID{UUID: deletedWorkspaceID, Valid: true}, + AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + } + rebound := chat + rebound.WorkspaceID = uuid.NullUUID{UUID: replacementWorkspaceID, Valid: true} + rebound.AgentID = uuid.NullUUID{} + updatedChat := rebound + updatedChat.BuildID = uuid.NullUUID{UUID: buildID, Valid: true} + updatedChat.AgentID = uuid.NullUUID{UUID: replacementAgent.ID, Valid: true} + + chatStateMu := &sync.Mutex{} + currentChat := chat + workspaceCtx := turnWorkspaceContext{ + server: &Server{db: db}, + chatStateMu: chatStateMu, + currentChat: ¤tChat, + loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil }, + } + defer workspaceCtx.close() + + // A concurrent create_workspace publishes the replacement binding while + // the deleted row is being read. + db.EXPECT().GetWorkspaceByID(gomock.Any(), deletedWorkspaceID). + DoAndReturn(func(context.Context, uuid.UUID) (database.Workspace, error) { + workspaceCtx.setCurrentChat(rebound) + return database.Workspace{ID: deletedWorkspaceID, Deleted: true}, nil + }). + Times(1) + expectLiveWorkspace(db, replacementWorkspaceID) + gomock.InOrder( + db.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), replacementWorkspaceID).Return([]database.WorkspaceAgent{replacementAgent}, nil), + db.EXPECT().GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), replacementWorkspaceID).Return(database.WorkspaceBuild{ID: buildID}, nil), + db.EXPECT().UpdateChatBuildAgentBinding(gomock.Any(), database.UpdateChatBuildAgentBindingParams{ + ID: chat.ID, + BuildID: uuid.NullUUID{UUID: buildID, Valid: true}, + AgentID: uuid.NullUUID{UUID: replacementAgent.ID, Valid: true}, + }).Return(updatedChat, nil), + ) + + chatSnapshot, agent, err := workspaceCtx.ensureWorkspaceAgent(ctx) + require.NoError(t, err) + require.Equal(t, updatedChat, chatSnapshot) + require.Equal(t, replacementAgent, agent) +} + func TestTurnWorkspaceContext_SelectWorkspaceClearsCachedState(t *testing.T) { t.Parallel() @@ -1554,6 +1674,7 @@ func TestTurnWorkspaceContext_EnsureWorkspaceAgentIgnoresCachedAgentForDifferent workspaceOneID := uuid.New() workspaceTwoID := uuid.New() + expectLiveWorkspace(db, workspaceTwoID) buildID := uuid.New() cachedAgent := database.WorkspaceAgent{ID: uuid.New()} resolvedAgent := database.WorkspaceAgent{ID: uuid.New()} @@ -2042,6 +2163,7 @@ func TestGetWorkspaceConn_StaleAgentRecovery(t *testing.T) { expectBestEffortContextRepin(db) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) oldAgentID := uuid.New() newAgentID := uuid.New() buildID := uuid.New() @@ -2168,6 +2290,7 @@ func TestGetWorkspaceConn_SameBuildAgentCrash(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() // Agent: disconnected (crashed on current build). @@ -2315,6 +2438,7 @@ func TestGetWorkspaceConn_StatusCheck(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2477,6 +2601,7 @@ func TestGetWorkspaceConn_DialTimeoutDisconnectedRecoveryThreshold(t *testing.T) db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2600,6 +2725,7 @@ func TestGetWorkspaceConn_DisconnectedStatusDialSuccessDoesNotEscalate(t *testin db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2670,6 +2796,7 @@ func TestGetWorkspaceConn_CacheHitDisconnectedRetriesDialBeforeEscalating(t *tes db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2748,6 +2875,7 @@ func TestGetWorkspaceConn_DialTimeout(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2820,6 +2948,7 @@ func TestGetWorkspaceConn_DialTimeoutParentCanceled(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2904,6 +3033,7 @@ func TestGetWorkspaceConn_PreflightExternalAgentTimedOut(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() resourceID := uuid.New() agent := database.WorkspaceAgent{ @@ -2978,6 +3108,7 @@ func TestGetWorkspaceConn_PreflightExternalAgentConnectingDials(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() resourceID := uuid.New() agent := database.WorkspaceAgent{ @@ -3048,6 +3179,7 @@ func TestGetWorkspaceConn_DialErrorNotMisclassifiedAsTimeout(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() resourceID := uuid.New() chat := database.Chat{ @@ -3601,3 +3733,11 @@ func TestResolveFallbackModelConfigID(t *testing.T) { require.ErrorIs(t, err, ErrInvalidModelConfigID) }) } + +// expectLiveWorkspace satisfies the workspace liveness read in +// loadWorkspaceAgentLocked for a workspace that still exists. +func expectLiveWorkspace(db *dbmock.MockStore, workspaceID uuid.UUID) { + db.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID). + Return(database.Workspace{ID: workspaceID}, nil). + AnyTimes() +} diff --git a/coderd/x/chatd/chattool/chattool.go b/coderd/x/chatd/chattool/chattool.go index dc99c384d79..100314510fc 100644 --- a/coderd/x/chatd/chattool/chattool.go +++ b/coderd/x/chatd/chattool/chattool.go @@ -16,6 +16,16 @@ import ( const templateNotAvailableMessage = "template not available for chat workspaces; use list_templates to find allowed templates" +// WorkspaceUnavailableHint follows errors that keep a tool from loading the +// chat's workspace so the model recovers instead of concluding it is blocked. +const WorkspaceUnavailableHint = "The workspace is probably gone; use the create_workspace tool to make a new one" + +func workspaceLoadErrorResponse(err error) fantasy.ToolResponse { + return fantasy.NewTextErrorResponse( + xerrors.Errorf("load workspace: %w. %s", err, WorkspaceUnavailableHint).Error(), + ) +} + func marshalToolResponse(result any) fantasy.ToolResponse { data, err := json.Marshal(result) if err != nil { diff --git a/coderd/x/chatd/chattool/createworkspace_test.go b/coderd/x/chatd/chattool/createworkspace_test.go new file mode 100644 index 00000000000..7ef75d47d11 --- /dev/null +++ b/coderd/x/chatd/chattool/createworkspace_test.go @@ -0,0 +1,112 @@ +package chattool_test + +import ( + "context" + "database/sql" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "charm.land/fantasy" + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/x/chatd/chattool" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// Dormancy auto-delete leaves dormant_at set, which switches the row's RBAC +// object to workspace_dormant. The chatd actor must still read it so the +// deleted binding is skipped and a replacement workspace gets created. +func TestCreateWorkspace_DormantDeletedWorkspace(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(t, db) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + ActiveVersionID: version.ID, + AgentsAllowed: true, + }) + // Link the version back so the owner's version read authorizes through + // the template, as in production. + require.NoError(t, db.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ + ID: version.ID, + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + UpdatedAt: version.UpdatedAt, + Name: version.Name, + Message: version.Message, + })) + wsResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: template.ID, + Deleted: true, + DormantAt: sql.NullTime{Time: time.Now(), Valid: true}, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionDelete, + }).Do() + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: wsResp.Workspace.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-create-dormant-deleted-workspace", + }) + + authzDB := dbauthz.New( + db, + rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), + slogtest.Make(t, nil), + testAccessControlStorePointer(), + ) + var createCalled atomic.Bool + tool := chattool.CreateWorkspace(authzDB, org.ID, chat.ID, chattool.CreateWorkspaceOptions{ + OwnerID: user.ID, + CreateFn: func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { + createCalled.Store(true) + require.Equal(t, template.ID, req.TemplateID) + return codersdk.Workspace{}, xerrors.New("creation stopped by test") + }, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + resp, err := tool.Run( + dbauthz.AsChatd(ctx), + fantasy.ToolCall{ + ID: "call-1", + Name: "create_workspace", + Input: fmt.Sprintf(`{"template_id": %q}`, template.ID), + }, + ) + require.NoError(t, err) + require.True(t, createCalled.Load(), "the deleted binding must not block creation: %s", resp.Content) + require.Contains(t, resp.Content, "creation stopped by test") + require.NotContains(t, resp.Content, "forbidden") +} diff --git a/coderd/x/chatd/chattool/startworkspace.go b/coderd/x/chatd/chattool/startworkspace.go index 650513d4beb..a2efc9d93ad 100644 --- a/coderd/x/chatd/chattool/startworkspace.go +++ b/coderd/x/chatd/chattool/startworkspace.go @@ -76,9 +76,7 @@ func StartWorkspace(db database.Store, chatID uuid.UUID, options StartWorkspaceO ws, err := db.GetWorkspaceByID(ctx, chat.WorkspaceID.UUID) if err != nil { - return fantasy.NewTextErrorResponse( - xerrors.Errorf("load workspace: %w", err).Error(), - ), nil + return workspaceLoadErrorResponse(err), nil } if ws.Deleted { return fantasy.NewTextErrorResponse( diff --git a/coderd/x/chatd/chattool/startworkspace_test.go b/coderd/x/chatd/chattool/startworkspace_test.go index 8955760e227..5629f41db30 100644 --- a/coderd/x/chatd/chattool/startworkspace_test.go +++ b/coderd/x/chatd/chattool/startworkspace_test.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" @@ -941,6 +942,110 @@ func TestStartWorkspace(t *testing.T) { require.NoError(t, err) require.Contains(t, resp.Content, "workspace was deleted") }) + + // Dormancy auto-delete leaves dormant_at set, which switches the row's + // RBAC object to workspace_dormant. The chatd actor must still read it + // so the deleted-workspace guidance is reached instead of a permission + // failure. + t.Run("DormantDeletedWorkspace", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(t, db) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + wsResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + Deleted: true, + DormantAt: sql.NullTime{Time: time.Now(), Valid: true}, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionDelete, + }).Do() + ws := wsResp.Workspace + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-dormant-deleted-workspace", + }) + + authzDB := dbauthz.New( + db, + rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), + slogtest.Make(t, nil), + testAccessControlStorePointer(), + ) + tool := chattool.StartWorkspace(authzDB, chat.ID, chattool.StartWorkspaceOptions{ + OwnerID: user.ID, + StartFn: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ codersdk.CreateWorkspaceBuildRequest) (codersdk.WorkspaceBuild, error) { + t.Fatal("StartFn should not be called for deleted workspace") + return codersdk.WorkspaceBuild{}, nil + }, + WorkspaceMu: &sync.Mutex{}, + }) + + resp, err := tool.Run( + dbauthz.AsChatd(ctx), + fantasy.ToolCall{ID: "call-1", Name: "start_workspace", Input: "{}"}, + ) + require.NoError(t, err) + require.Contains(t, resp.Content, "workspace was deleted") + require.Contains(t, resp.Content, "create_workspace") + require.NotContains(t, resp.Content, "forbidden") + }) + + t.Run("LoadWorkspaceErrorGuidance", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(t, db) + org := dbgen.Organization(t, db, database.Organization{}) + ws := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + }).Do().Workspace + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-load-workspace-error", + }) + + tool := chattool.StartWorkspace(&failingWorkspaceStore{Store: db}, chat.ID, chattool.StartWorkspaceOptions{ + StartFn: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ codersdk.CreateWorkspaceBuildRequest) (codersdk.WorkspaceBuild, error) { + t.Fatal("StartFn should not be called when the workspace cannot be loaded") + return codersdk.WorkspaceBuild{}, nil + }, + WorkspaceMu: &sync.Mutex{}, + }) + + resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "call-1", Name: "start_workspace", Input: "{}"}) + require.NoError(t, err) + require.True(t, resp.IsError) + require.Contains(t, resp.Content, "load workspace: workspace store offline") + require.Contains(t, resp.Content, chattool.WorkspaceUnavailableHint) + }) +} + +// failingWorkspaceStore fails every workspace lookup so tests can observe +// the recovery guidance attached to load errors. +type failingWorkspaceStore struct { + database.Store +} + +func (*failingWorkspaceStore) GetWorkspaceByID(context.Context, uuid.UUID) (database.Workspace, error) { + return database.Workspace{}, xerrors.New("workspace store offline") } // seedModelConfig inserts a provider and model config for testing. diff --git a/coderd/x/chatd/chattool/stopworkspace.go b/coderd/x/chatd/chattool/stopworkspace.go index 1aea9ad8369..6cf34b1e14d 100644 --- a/coderd/x/chatd/chattool/stopworkspace.go +++ b/coderd/x/chatd/chattool/stopworkspace.go @@ -77,9 +77,7 @@ func StopWorkspace(db database.Store, chatID uuid.UUID, options StopWorkspaceOpt ws, err := db.GetWorkspaceByID(ctx, chat.WorkspaceID.UUID) if err != nil { - return fantasy.NewTextErrorResponse( - xerrors.Errorf("load workspace: %w", err).Error(), - ), nil + return workspaceLoadErrorResponse(err), nil } if ws.Deleted { return fantasy.NewTextErrorResponse( @@ -105,9 +103,7 @@ func StopWorkspace(db database.Store, chatID uuid.UUID, options StopWorkspaceOpt // have completed while this tool was blocked. ws, err = db.GetWorkspaceByID(ctx, ws.ID) if err != nil { - return fantasy.NewTextErrorResponse( - xerrors.Errorf("load workspace: %w", err).Error(), - ), nil + return workspaceLoadErrorResponse(err), nil } if ws.Deleted { return fantasy.NewTextErrorResponse( diff --git a/coderd/x/chatd/chattool/stopworkspace_test.go b/coderd/x/chatd/chattool/stopworkspace_test.go index 4133ba223da..dc88a4426d1 100644 --- a/coderd/x/chatd/chattool/stopworkspace_test.go +++ b/coderd/x/chatd/chattool/stopworkspace_test.go @@ -11,13 +11,16 @@ import ( "charm.land/fantasy" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -101,6 +104,64 @@ func TestStopWorkspace(t *testing.T) { require.Contains(t, resp.Content, "create_workspace") }) + // Dormancy auto-delete leaves dormant_at set, which switches the row's + // RBAC object to workspace_dormant. The chatd actor must still read it + // so the deleted-workspace guidance is reached instead of a permission + // failure. + t.Run("DormantDeletedWorkspace", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(t, db) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + wsResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + Deleted: true, + DormantAt: sql.NullTime{Time: time.Now(), Valid: true}, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionDelete, + }).Do() + ws := wsResp.Workspace + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-stop-dormant-deleted-workspace", + }) + + authzDB := dbauthz.New( + db, + rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), + slogtest.Make(t, nil), + testAccessControlStorePointer(), + ) + tool := chattool.StopWorkspace(authzDB, chat.ID, chattool.StopWorkspaceOptions{ + StopFn: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ codersdk.CreateWorkspaceBuildRequest) (codersdk.WorkspaceBuild, error) { + t.Fatal("StopFn should not be called for deleted workspace") + return codersdk.WorkspaceBuild{}, nil + }, + WorkspaceMu: &sync.Mutex{}, + }) + + resp, err := tool.Run( + dbauthz.AsChatd(ctx), + fantasy.ToolCall{ID: "call-1", Name: "stop_workspace", Input: "{}"}, + ) + require.NoError(t, err) + require.Contains(t, resp.Content, "workspace was deleted") + require.Contains(t, resp.Content, "create_workspace") + require.NotContains(t, resp.Content, "forbidden") + }) + t.Run("AlreadyStopped", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong)