diff --git a/cli/task.go b/cli/task.go index 865d1869bf850..97aa85d4f5de4 100644 --- a/cli/task.go +++ b/cli/task.go @@ -17,6 +17,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command { r.taskDelete(), r.taskList(), r.taskLogs(), + r.taskPause(), r.taskSend(), r.taskStatus(), }, diff --git a/cli/task_logs_test.go b/cli/task_logs_test.go index 4c01aed7a7f7a..fefbd70bf4266 100644 --- a/cli/task_logs_test.go +++ b/cli/task_logs_test.go @@ -41,8 +41,7 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) - userClient := client // user already has access to their own workspace + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) inv, root := clitest.New(t, "task", "logs", task.Name, "--output", "json") output := clitest.Capture(inv) @@ -65,8 +64,7 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) inv, root := clitest.New(t, "task", "logs", task.ID.String(), "--output", "json") output := clitest.Capture(inv) @@ -89,8 +87,7 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsOK(testMessages)) inv, root := clitest.New(t, "task", "logs", task.ID.String()) output := clitest.Capture(inv) @@ -144,8 +141,7 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError)) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskLogsErr(assert.AnError)) inv, root := clitest.New(t, "task", "logs", task.ID.String()) clitest.SetupConfig(t, userClient, root) @@ -201,8 +197,7 @@ func Test_TaskLogs_Golden(t *testing.T) { t.Run("SnapshotWithoutLogs_NoSnapshotCaptured", func(t *testing.T) { t.Parallel() - client, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused) - userClient := client + userClient, task := setupCLITaskTestWithoutSnapshot(t, codersdk.TaskStatusPaused) inv, root := clitest.New(t, "task", "logs", task.Name) output := clitest.Capture(inv) diff --git a/cli/task_pause.go b/cli/task_pause.go new file mode 100644 index 0000000000000..cae2cba6be815 --- /dev/null +++ b/cli/task_pause.go @@ -0,0 +1,90 @@ +package cli + +import ( + "fmt" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" + "github.com/coder/serpent" +) + +func (r *RootCmd) taskPause() *serpent.Command { + cmd := &serpent.Command{ + Use: "pause ", + Short: "Pause a task", + Long: FormatExamples( + Example{ + Description: "Pause a task by name", + Command: "coder task pause my-task", + }, + Example{ + Description: "Pause another user's task", + Command: "coder task pause alice/my-task", + }, + Example{ + Description: "Pause a task without confirmation", + Command: "coder task pause my-task --yes", + }, + ), + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + cliui.SkipPromptOption(), + }, + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + client, err := r.InitClient(inv) + if err != nil { + return err + } + + task, err := client.TaskByIdentifier(ctx, inv.Args[0]) + if err != nil { + return xerrors.Errorf("resolve task %q: %w", inv.Args[0], err) + } + + display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) + + if task.Status == codersdk.TaskStatusPaused { + return xerrors.Errorf("task %q is already paused", display) + } + + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: fmt.Sprintf("Pause task %s?", pretty.Sprint(cliui.DefaultStyles.Code, display)), + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + resp, err := client.PauseTask(ctx, task.OwnerName, task.ID) + if err != nil { + return xerrors.Errorf("pause task %q: %w", display, err) + } + + if resp.WorkspaceBuild == nil { + return xerrors.Errorf("pause task %q: no workspace build returned", display) + } + + err = cliui.WorkspaceBuild(ctx, inv.Stdout, client, resp.WorkspaceBuild.ID) + if err != nil { + return xerrors.Errorf("watch pause build for task %q: %w", display, err) + } + + _, _ = fmt.Fprintf( + inv.Stdout, + "\nThe %s task has been paused at %s!\n", + cliui.Keyword(task.Name), + cliui.Timestamp(time.Now()), + ) + return nil + }, + } + return cmd +} diff --git a/cli/task_pause_test.go b/cli/task_pause_test.go new file mode 100644 index 0000000000000..761590a859db5 --- /dev/null +++ b/cli/task_pause_test.go @@ -0,0 +1,144 @@ +package cli_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestExpTaskPause(t *testing.T) { + t.Parallel() + + t.Run("WithYesFlag", func(t *testing.T) { + t.Parallel() + + // Given: A running task + setupCtx := testutil.Context(t, testutil.WaitLong) + _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + + // When: We attempt to pause the task + inv, root := clitest.New(t, "task", "pause", task.Name, "--yes") + output := clitest.Capture(inv) + clitest.SetupConfig(t, userClient, root) + + // Then: Expect the task to be paused + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "has been paused") + + updated, err := userClient.TaskByIdentifier(ctx, task.Name) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusPaused, updated.Status) + }) + + // OtherUserTask verifies that an admin can pause a task owned by + // another user using the "owner/name" identifier format. + t.Run("OtherUserTask", func(t *testing.T) { + t.Parallel() + + // Given: A different user's running task + setupCtx := testutil.Context(t, testutil.WaitLong) + adminClient, _, task := setupCLITaskTest(setupCtx, t, nil) + + // When: We attempt to pause their task + identifier := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) + inv, root := clitest.New(t, "task", "pause", identifier, "--yes") + output := clitest.Capture(inv) + clitest.SetupConfig(t, adminClient, root) + + // Then: We expect the task to be paused + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "has been paused") + + updated, err := adminClient.TaskByIdentifier(ctx, identifier) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusPaused, updated.Status) + }) + + t.Run("PromptConfirm", func(t *testing.T) { + t.Parallel() + + // Given: A running task + setupCtx := testutil.Context(t, testutil.WaitLong) + _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + + // When: We attempt to pause the task + inv, root := clitest.New(t, "task", "pause", task.Name) + clitest.SetupConfig(t, userClient, root) + + // And: We confirm we want to pause the task + ctx := testutil.Context(t, testutil.WaitMedium) + inv = inv.WithContext(ctx) + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + pty.ExpectMatchContext(ctx, "Pause task") + pty.WriteLine("yes") + + // Then: We expect the task to be paused + pty.ExpectMatchContext(ctx, "has been paused") + require.NoError(t, w.Wait()) + + updated, err := userClient.TaskByIdentifier(ctx, task.Name) + require.NoError(t, err) + require.Equal(t, codersdk.TaskStatusPaused, updated.Status) + }) + + t.Run("PromptDecline", func(t *testing.T) { + t.Parallel() + + // Given: A running task + setupCtx := testutil.Context(t, testutil.WaitLong) + _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + + // When: We attempt to pause the task + inv, root := clitest.New(t, "task", "pause", task.Name) + clitest.SetupConfig(t, userClient, root) + + // But: We say no at the confirmation screen + ctx := testutil.Context(t, testutil.WaitMedium) + inv = inv.WithContext(ctx) + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + pty.ExpectMatchContext(ctx, "Pause task") + pty.WriteLine("no") + require.Error(t, w.Wait()) + + // Then: We expect the task to not be paused + updated, err := userClient.TaskByIdentifier(ctx, task.Name) + require.NoError(t, err) + require.NotEqual(t, codersdk.TaskStatusPaused, updated.Status) + }) + + t.Run("TaskAlreadyPaused", func(t *testing.T) { + t.Parallel() + + // Given: A running task + setupCtx := testutil.Context(t, testutil.WaitLong) + _, userClient, task := setupCLITaskTest(setupCtx, t, nil) + + // And: We paused the running task + ctx := testutil.Context(t, testutil.WaitMedium) + resp, err := userClient.PauseTask(ctx, task.OwnerName, task.ID) + require.NoError(t, err) + require.NotNil(t, resp.WorkspaceBuild) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, resp.WorkspaceBuild.ID) + + // When: We attempt to pause the task again + inv, root := clitest.New(t, "task", "pause", task.Name, "--yes") + clitest.SetupConfig(t, userClient, root) + + // Then: We expect to get an error that the task is already paused + err = inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "is already paused") + }) +} diff --git a/cli/task_send_test.go b/cli/task_send_test.go index 1648244a020ad..0aa6713404fb0 100644 --- a/cli/task_send_test.go +++ b/cli/task_send_test.go @@ -25,8 +25,7 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) var stdout strings.Builder inv, root := clitest.New(t, "task", "send", task.Name, "carry on with the task") @@ -42,8 +41,7 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) var stdout strings.Builder inv, root := clitest.New(t, "task", "send", task.ID.String(), "carry on with the task") @@ -59,8 +57,7 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - client, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) - userClient := client + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendOK(t, "carry on with the task", "you got it")) var stdout strings.Builder inv, root := clitest.New(t, "task", "send", task.Name, "--stdin") @@ -113,7 +110,7 @@ func Test_TaskSend(t *testing.T) { t.Parallel() setupCtx := testutil.Context(t, testutil.WaitLong) - userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(t, assert.AnError)) + _, userClient, task := setupCLITaskTest(setupCtx, t, fakeAgentAPITaskSendErr(t, assert.AnError)) var stdout strings.Builder inv, root := clitest.New(t, "task", "send", task.Name, "some task input") diff --git a/cli/task_test.go b/cli/task_test.go index 8c5bdf9b0adf6..b4d117b2a3a14 100644 --- a/cli/task_test.go +++ b/cli/task_test.go @@ -120,6 +120,23 @@ func Test_Tasks(t *testing.T) { require.Equal(t, logs[2].Type, codersdk.TaskLogTypeOutput, "third message should be an output") }, }, + { + name: "pause task", + cmdArgs: []string{"task", "pause", taskName, "--yes"}, + assertFn: func(stdout string, userClient *codersdk.Client) { + require.Contains(t, stdout, "has been paused", "pause output should confirm task was paused") + }, + }, + { + name: "get task status after pause", + cmdArgs: []string{"task", "status", taskName, "--output", "json"}, + assertFn: func(stdout string, userClient *codersdk.Client) { + var task codersdk.Task + require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status") + require.Equal(t, taskName, task.Name, "task name should match") + require.Equal(t, codersdk.TaskStatusPaused, task.Status, "task should be paused") + }, + }, { name: "delete task", cmdArgs: []string{"task", "delete", taskName, "--yes"}, @@ -238,17 +255,17 @@ func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Mes // setupCLITaskTest creates a test workspace with an AI task template and agent, // with a fake agent API configured with the provided set of handlers. // Returns the user client and workspace. -func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (*codersdk.Client, codersdk.Task) { +func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[string]http.HandlerFunc) (ownerClient *codersdk.Client, memberClient *codersdk.Client, task codersdk.Task) { t.Helper() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) - userClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + ownerClient = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, ownerClient) + userClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) fakeAPI := startFakeAgentAPI(t, agentAPIHandlers) authToken := uuid.NewString() - template := createAITaskTemplate(t, client, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken)) + template := createAITaskTemplate(t, ownerClient, owner.OrganizationID, withSidebarURL(fakeAPI.URL()), withAgentToken(authToken)) wantPrompt := "test prompt" task, err := userClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ @@ -262,17 +279,17 @@ func setupCLITaskTest(ctx context.Context, t *testing.T, agentAPIHandlers map[st require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") workspace, err := userClient.Workspace(ctx, task.WorkspaceID.UUID) require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) - agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken)) - _ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) { + agentClient := agentsdk.New(userClient.URL, agentsdk.WithFixedToken(authToken)) + _ = agenttest.New(t, userClient.URL, authToken, func(o *agent.Options) { o.Client = agentClient }) - coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID). + coderdtest.NewWorkspaceAgentWaiter(t, userClient, workspace.ID). WaitFor(coderdtest.AgentsReady) - return userClient, task + return ownerClient, userClient, task } // setupCLITaskTestWithSnapshot creates a task in the specified status with a log snapshot. diff --git a/cli/testdata/coder_task_--help.golden b/cli/testdata/coder_task_--help.golden index c6fa004de06af..2b912746b4bcc 100644 --- a/cli/testdata/coder_task_--help.golden +++ b/cli/testdata/coder_task_--help.golden @@ -12,6 +12,7 @@ SUBCOMMANDS: delete Delete tasks list List tasks logs Show a task's logs + pause Pause a task send Send input to a task status Show the status of a task. diff --git a/cli/testdata/coder_task_pause_--help.golden b/cli/testdata/coder_task_pause_--help.golden new file mode 100644 index 0000000000000..e6c6f5670333c --- /dev/null +++ b/cli/testdata/coder_task_pause_--help.golden @@ -0,0 +1,25 @@ +coder v0.0.0-devel + +USAGE: + coder task pause [flags] + + Pause a task + + - Pause a task by name: + + $ coder task pause my-task + + - Pause another user's task: + + $ coder task pause alice/my-task + + - Pause a task without confirmation: + + $ coder task pause my-task --yes + +OPTIONS: + -y, --yes bool + Bypass confirmation prompts. + +——— +Run `coder --help` for a list of global options. diff --git a/docs/manifest.json b/docs/manifest.json index d505a7305a0fb..a1faf1a4b7fac 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -2009,6 +2009,11 @@ "description": "Show a task's logs", "path": "reference/cli/task_logs.md" }, + { + "title": "task pause", + "description": "Pause a task", + "path": "reference/cli/task_pause.md" + }, { "title": "task send", "description": "Send input to a task", diff --git a/docs/reference/cli/task.md b/docs/reference/cli/task.md index 9f70c9c4d5022..cb6a1e42c2bb3 100644 --- a/docs/reference/cli/task.md +++ b/docs/reference/cli/task.md @@ -21,5 +21,6 @@ coder task | [delete](./task_delete.md) | Delete tasks | | [list](./task_list.md) | List tasks | | [logs](./task_logs.md) | Show a task's logs | +| [pause](./task_pause.md) | Pause a task | | [send](./task_send.md) | Send input to a task | | [status](./task_status.md) | Show the status of a task. | diff --git a/docs/reference/cli/task_pause.md b/docs/reference/cli/task_pause.md new file mode 100644 index 0000000000000..34c14199e10f7 --- /dev/null +++ b/docs/reference/cli/task_pause.md @@ -0,0 +1,36 @@ + +# task pause + +Pause a task + +## Usage + +```console +coder task pause [flags] +``` + +## Description + +```console + - Pause a task by name: + + $ coder task pause my-task + + - Pause another user's task: + + $ coder task pause alice/my-task + + - Pause a task without confirmation: + + $ coder task pause my-task --yes +``` + +## Options + +### -y, --yes + +| | | +|------|-------------------| +| Type | bool | + +Bypass confirmation prompts.