Skip to content

Commit de4d5f0

Browse files
feat(cli): add coder task pause command
Add a new CLI command to pause a running task. The command accepts a task identifier (UUID, name, or owner/name format) and triggers a workspace build transition to stop the task. Features: - Interactive confirmation prompt with --yes flag to bypass - Build progress streaming when a workspace build is returned - Supports task resolution by UUID, name, or owner/name Includes unit tests with mock HTTP server covering happy paths, error handling, prompt confirmation/decline, and an integration test in the full task lifecycle sequence. Closes coder/internal#1263
1 parent fa2481c commit de4d5f0

6 files changed

Lines changed: 347 additions & 0 deletions

File tree

‎cli/task.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command {
1717
r.taskDelete(),
1818
r.taskList(),
1919
r.taskLogs(),
20+
r.taskPause(),
2021
r.taskSend(),
2122
r.taskStatus(),
2223
},

‎cli/task_pause.go‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"golang.org/x/xerrors"
8+
9+
"github.com/coder/coder/v2/cli/cliui"
10+
"github.com/coder/pretty"
11+
"github.com/coder/serpent"
12+
)
13+
14+
func (r *RootCmd) taskPause() *serpent.Command {
15+
cmd := &serpent.Command{
16+
Use: "pause <task>",
17+
Short: "Pause a task",
18+
Long: FormatExamples(
19+
Example{
20+
Description: "Pause a task by name.",
21+
Command: "coder task pause my-task",
22+
},
23+
Example{
24+
Description: "Pause a task without confirmation.",
25+
Command: "coder task pause my-task --yes",
26+
},
27+
),
28+
Middleware: serpent.Chain(
29+
serpent.RequireNArgs(1),
30+
),
31+
Options: serpent.OptionSet{
32+
cliui.SkipPromptOption(),
33+
},
34+
Handler: func(inv *serpent.Invocation) error {
35+
ctx := inv.Context()
36+
client, err := r.InitClient(inv)
37+
if err != nil {
38+
return err
39+
}
40+
41+
task, err := client.TaskByIdentifier(ctx, inv.Args[0])
42+
if err != nil {
43+
return xerrors.Errorf("resolve task %q: %w", inv.Args[0], err)
44+
}
45+
46+
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
47+
48+
_, err = cliui.Prompt(inv, cliui.PromptOptions{
49+
Text: fmt.Sprintf("Pause task %s?", pretty.Sprint(cliui.DefaultStyles.Code, display)),
50+
IsConfirm: true,
51+
Default: cliui.ConfirmNo,
52+
})
53+
if err != nil {
54+
return err
55+
}
56+
57+
resp, err := client.PauseTask(ctx, task.OwnerName, task.ID)
58+
if err != nil {
59+
return xerrors.Errorf("pause task %q: %w", display, err)
60+
}
61+
62+
if resp.WorkspaceBuild != nil {
63+
err = cliui.WorkspaceBuild(ctx, inv.Stdout, client, resp.WorkspaceBuild.ID)
64+
if err != nil {
65+
return xerrors.Errorf("watch pause build for task %q: %w", display, err)
66+
}
67+
} else {
68+
cliui.Warn(inv.Stderr,
69+
"Pause was accepted but no workspace build was returned.",
70+
"The task may not be fully paused yet.",
71+
)
72+
}
73+
74+
_, _ = fmt.Fprintf(
75+
inv.Stdout,
76+
"\nThe %s task has been paused at %s!\n",
77+
cliui.Keyword(task.Name),
78+
cliui.Timestamp(time.Now()),
79+
)
80+
return nil
81+
},
82+
}
83+
return cmd
84+
}

‎cli/task_pause_test.go‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/google/uuid"
10+
"github.com/stretchr/testify/require"
11+
"golang.org/x/xerrors"
12+
13+
"github.com/coder/coder/v2/cli/clitest"
14+
"github.com/coder/coder/v2/coderd/httpapi"
15+
"github.com/coder/coder/v2/codersdk"
16+
"github.com/coder/coder/v2/pty/ptytest"
17+
"github.com/coder/coder/v2/testutil"
18+
)
19+
20+
func TestExpTaskPause(t *testing.T) {
21+
t.Parallel()
22+
23+
type testCase struct {
24+
name string
25+
args []string
26+
promptYes bool
27+
promptNo bool
28+
wantErr string
29+
wantPausedMsg bool
30+
buildHandler func() http.HandlerFunc
31+
}
32+
33+
const (
34+
id1 = "11111111-1111-1111-1111-111111111111"
35+
id2 = "22222222-2222-2222-2222-222222222222"
36+
id3 = "33333333-3333-3333-3333-333333333333"
37+
)
38+
39+
cases := []testCase{
40+
{
41+
name: "OK_ByName",
42+
args: []string{"my-task"},
43+
promptYes: true,
44+
buildHandler: func() http.HandlerFunc {
45+
taskID := uuid.MustParse(id1)
46+
return func(w http.ResponseWriter, r *http.Request) {
47+
switch {
48+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/my-task":
49+
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
50+
ID: taskID,
51+
Name: "my-task",
52+
OwnerName: "me",
53+
})
54+
case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id1+"/pause":
55+
httpapi.Write(r.Context(), w, http.StatusAccepted, codersdk.PauseTaskResponse{})
56+
default:
57+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
58+
}
59+
}
60+
},
61+
wantPausedMsg: true,
62+
},
63+
{
64+
name: "OK_ByUUID",
65+
args: []string{id2},
66+
promptYes: true,
67+
buildHandler: func() http.HandlerFunc {
68+
return func(w http.ResponseWriter, r *http.Request) {
69+
switch {
70+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/"+id2:
71+
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
72+
ID: uuid.MustParse(id2),
73+
OwnerName: "me",
74+
Name: "uuid-task",
75+
})
76+
case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id2+"/pause":
77+
httpapi.Write(r.Context(), w, http.StatusAccepted, codersdk.PauseTaskResponse{})
78+
default:
79+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
80+
}
81+
}
82+
},
83+
wantPausedMsg: true,
84+
},
85+
{
86+
name: "YesFlag",
87+
args: []string{"--yes", "my-task"},
88+
buildHandler: func() http.HandlerFunc {
89+
taskID := uuid.MustParse(id3)
90+
return func(w http.ResponseWriter, r *http.Request) {
91+
switch {
92+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/my-task":
93+
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
94+
ID: taskID,
95+
Name: "my-task",
96+
OwnerName: "me",
97+
})
98+
case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id3+"/pause":
99+
httpapi.Write(r.Context(), w, http.StatusAccepted, codersdk.PauseTaskResponse{})
100+
default:
101+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
102+
}
103+
}
104+
},
105+
wantPausedMsg: true,
106+
},
107+
{
108+
name: "ResolveError",
109+
args: []string{"doesnotexist"},
110+
wantErr: "resolve task",
111+
buildHandler: func() http.HandlerFunc {
112+
return func(w http.ResponseWriter, r *http.Request) {
113+
switch {
114+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/doesnotexist":
115+
httpapi.Write(r.Context(), w, http.StatusNotFound, codersdk.Response{
116+
Message: "Task not found.",
117+
})
118+
default:
119+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
120+
}
121+
}
122+
},
123+
},
124+
{
125+
name: "PauseError",
126+
args: []string{"bad-task"},
127+
promptYes: true,
128+
wantErr: "pause task",
129+
buildHandler: func() http.HandlerFunc {
130+
taskID := uuid.MustParse(id1)
131+
return func(w http.ResponseWriter, r *http.Request) {
132+
switch {
133+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/bad-task":
134+
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
135+
ID: taskID,
136+
Name: "bad-task",
137+
OwnerName: "me",
138+
})
139+
case r.Method == http.MethodPost && r.URL.Path == "/api/experimental/tasks/me/"+id1+"/pause":
140+
httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{
141+
Message: "Internal error.",
142+
Detail: "boom",
143+
})
144+
default:
145+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
146+
}
147+
}
148+
},
149+
},
150+
{
151+
name: "PromptDeclined",
152+
args: []string{"my-task"},
153+
promptNo: true,
154+
wantErr: "canceled",
155+
buildHandler: func() http.HandlerFunc {
156+
taskID := uuid.MustParse(id1)
157+
return func(w http.ResponseWriter, r *http.Request) {
158+
switch {
159+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/my-task":
160+
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
161+
ID: taskID,
162+
Name: "my-task",
163+
OwnerName: "me",
164+
})
165+
default:
166+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
167+
}
168+
}
169+
},
170+
},
171+
}
172+
173+
for _, tc := range cases {
174+
t.Run(tc.name, func(t *testing.T) {
175+
t.Parallel()
176+
177+
ctx := testutil.Context(t, testutil.WaitMedium)
178+
179+
srv := httptest.NewServer(tc.buildHandler())
180+
t.Cleanup(srv.Close)
181+
182+
client := codersdk.New(testutil.MustURL(t, srv.URL))
183+
184+
args := append([]string{"task", "pause"}, tc.args...)
185+
inv, root := clitest.New(t, args...)
186+
inv = inv.WithContext(ctx)
187+
clitest.SetupConfig(t, client, root)
188+
189+
var runErr error
190+
var outBuf bytes.Buffer
191+
if tc.promptYes || tc.promptNo {
192+
pty := ptytest.New(t).Attach(inv)
193+
w := clitest.StartWithWaiter(t, inv)
194+
pty.ExpectMatchContext(ctx, "Pause task")
195+
if tc.promptYes {
196+
pty.WriteLine("yes")
197+
} else {
198+
pty.WriteLine("no")
199+
}
200+
if tc.wantPausedMsg {
201+
pty.ExpectMatchContext(ctx, "has been paused")
202+
}
203+
runErr = w.Wait()
204+
} else {
205+
inv.Stdout = &outBuf
206+
inv.Stderr = &outBuf
207+
runErr = inv.Run()
208+
}
209+
210+
if tc.wantErr != "" {
211+
require.ErrorContains(t, runErr, tc.wantErr)
212+
} else {
213+
require.NoError(t, runErr)
214+
}
215+
216+
// For non-PTY tests, verify output from the buffer.
217+
if tc.wantPausedMsg && !tc.promptYes && !tc.promptNo {
218+
output := outBuf.String()
219+
require.Contains(t, output, "has been paused")
220+
}
221+
})
222+
}
223+
}

‎cli/task_test.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,23 @@ func Test_Tasks(t *testing.T) {
120120
require.Equal(t, logs[2].Type, codersdk.TaskLogTypeOutput, "third message should be an output")
121121
},
122122
},
123+
{
124+
name: "pause task",
125+
cmdArgs: []string{"task", "pause", taskName, "--yes"},
126+
assertFn: func(stdout string, userClient *codersdk.Client) {
127+
require.Contains(t, stdout, "has been paused", "pause output should confirm task was paused")
128+
},
129+
},
130+
{
131+
name: "get task status after pause",
132+
cmdArgs: []string{"task", "status", taskName, "--output", "json"},
133+
assertFn: func(stdout string, userClient *codersdk.Client) {
134+
var task codersdk.Task
135+
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status")
136+
require.Equal(t, taskName, task.Name, "task name should match")
137+
require.Equal(t, codersdk.TaskStatusPaused, task.Status, "task should be paused")
138+
},
139+
},
123140
{
124141
name: "delete task",
125142
cmdArgs: []string{"task", "delete", taskName, "--yes"},

‎cli/testdata/coder_task_--help.golden‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ SUBCOMMANDS:
1212
delete Delete tasks
1313
list List tasks
1414
logs Show a task's logs
15+
pause Pause a task
1516
send Send input to a task
1617
status Show the status of a task.
1718

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
coder v0.0.0-devel
2+
3+
USAGE:
4+
coder task pause [flags] <task>
5+
6+
Pause a task
7+
8+
- Pause a task by name.:
9+
10+
$ coder task pause my-task
11+
12+
- Pause a task without confirmation.:
13+
14+
$ coder task pause my-task --yes
15+
16+
OPTIONS:
17+
-y, --yes bool
18+
Bypass confirmation prompts.
19+
20+
———
21+
Run `coder --help` for a list of global options.

0 commit comments

Comments
 (0)