diff --git a/cli/exp_task.go b/cli/exp_task.go index 005138050b..9d4f1d814f 100644 --- a/cli/exp_task.go +++ b/cli/exp_task.go @@ -16,6 +16,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command { r.taskList(), r.taskCreate(), r.taskStatus(), + r.taskDelete(), }, } return cmd diff --git a/cli/exp_task_delete.go b/cli/exp_task_delete.go new file mode 100644 index 0000000000..5429ef2809 --- /dev/null +++ b/cli/exp_task_delete.go @@ -0,0 +1,96 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/pretty" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) taskDelete() *serpent.Command { + client := new(codersdk.Client) + + cmd := &serpent.Command{ + Use: "delete [ ...]", + Short: "Delete experimental tasks", + Middleware: serpent.Chain( + serpent.RequireRangeArgs(1, -1), + r.InitClient(client), + ), + Options: serpent.OptionSet{ + cliui.SkipPromptOption(), + }, + Handler: func(inv *serpent.Invocation) error { + ctx := inv.Context() + exp := codersdk.NewExperimentalClient(client) + + type toDelete struct { + ID uuid.UUID + Owner string + Display string + } + + var items []toDelete + for _, identifier := range inv.Args { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return xerrors.New("task identifier cannot be empty or whitespace") + } + + // Check task identifier, try UUID first. + if id, err := uuid.Parse(identifier); err == nil { + task, err := exp.TaskByID(ctx, id) + if err != nil { + return xerrors.Errorf("resolve task %q: %w", identifier, err) + } + display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name) + items = append(items, toDelete{ID: id, Display: display, Owner: task.OwnerName}) + continue + } + + // Non-UUID, treat as a workspace identifier (name or owner/name). + ws, err := namedWorkspace(ctx, client, identifier) + if err != nil { + return xerrors.Errorf("resolve task %q: %w", identifier, err) + } + display := ws.FullName() + items = append(items, toDelete{ID: ws.ID, Display: display, Owner: ws.OwnerName}) + } + + // Confirm deletion of the tasks. + var displayList []string + for _, it := range items { + displayList = append(displayList, it.Display) + } + _, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: fmt.Sprintf("Delete these tasks: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(displayList, ", "))), + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + for _, item := range items { + if err := exp.DeleteTask(ctx, item.Owner, item.ID); err != nil { + return xerrors.Errorf("delete task %q: %w", item.Display, err) + } + _, _ = fmt.Fprintln( + inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, item.Display)+" at "+cliui.Timestamp(time.Now()), + ) + } + + return nil + }, + } + + return cmd +} diff --git a/cli/exp_task_delete_test.go b/cli/exp_task_delete_test.go new file mode 100644 index 0000000000..0b288c4ca3 --- /dev/null +++ b/cli/exp_task_delete_test.go @@ -0,0 +1,224 @@ +package cli_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestExpTaskDelete(t *testing.T) { + t.Parallel() + + type testCounters struct { + deleteCalls atomic.Int64 + nameResolves atomic.Int64 + } + type handlerBuilder func(c *testCounters) http.HandlerFunc + + type testCase struct { + name string + args []string + promptYes bool + wantErr bool + wantDeleteCalls int64 + wantNameResolves int64 + wantDeletedMessage int + buildHandler handlerBuilder + } + + const ( + id1 = "11111111-1111-1111-1111-111111111111" + id2 = "22222222-2222-2222-2222-222222222222" + id3 = "33333333-3333-3333-3333-333333333333" + id4 = "44444444-4444-4444-4444-444444444444" + id5 = "55555555-5555-5555-5555-555555555555" + ) + + cases := []testCase{ + { + name: "Prompted_ByName_OK", + args: []string{"exists"}, + promptYes: true, + buildHandler: func(c *testCounters) http.HandlerFunc { + taskID := uuid.MustParse(id1) + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/exists": + c.nameResolves.Add(1) + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{ + ID: taskID, + Name: "exists", + OwnerName: "me", + }) + case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id1: + c.deleteCalls.Add(1) + w.WriteHeader(http.StatusAccepted) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + wantDeleteCalls: 1, + wantNameResolves: 1, + }, + { + name: "Prompted_ByUUID_OK", + args: []string{id2}, + promptYes: true, + buildHandler: func(c *testCounters) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id2: + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse(id2), + OwnerName: "me", + Name: "uuid-task", + }) + case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id2: + c.deleteCalls.Add(1) + w.WriteHeader(http.StatusAccepted) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + wantDeleteCalls: 1, + }, + { + name: "Multiple_YesFlag", + args: []string{"--yes", "first", id4}, + buildHandler: func(c *testCounters) http.HandlerFunc { + firstID := uuid.MustParse(id3) + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/first": + c.nameResolves.Add(1) + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{ + ID: firstID, + Name: "first", + OwnerName: "me", + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id4: + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{ + ID: uuid.MustParse(id4), + OwnerName: "me", + Name: "uuid-task-2", + }) + case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id3: + c.deleteCalls.Add(1) + w.WriteHeader(http.StatusAccepted) + case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id4: + c.deleteCalls.Add(1) + w.WriteHeader(http.StatusAccepted) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + wantDeleteCalls: 2, + wantNameResolves: 1, + wantDeletedMessage: 2, + }, + { + name: "ResolveNameError", + args: []string{"doesnotexist"}, + wantErr: true, + buildHandler: func(_ *testCounters) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/doesnotexist": + httpapi.ResourceNotFound(w) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + }, + { + name: "DeleteError", + args: []string{"bad"}, + promptYes: true, + wantErr: true, + buildHandler: func(c *testCounters) http.HandlerFunc { + taskID := uuid.MustParse(id5) + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/bad": + c.nameResolves.Add(1) + httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{ + ID: taskID, + Name: "bad", + OwnerName: "me", + }) + case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id5: + httpapi.InternalServerError(w, xerrors.New("boom")) + default: + httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path)) + } + } + }, + wantNameResolves: 1, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + + var counters testCounters + srv := httptest.NewServer(tc.buildHandler(&counters)) + t.Cleanup(srv.Close) + + client := codersdk.New(testutil.MustURL(t, srv.URL)) + + args := append([]string{"exp", "task", "delete"}, tc.args...) + inv, root := clitest.New(t, args...) + inv = inv.WithContext(ctx) + clitest.SetupConfig(t, client, root) + + var runErr error + var outBuf bytes.Buffer + if tc.promptYes { + pty := ptytest.New(t).Attach(inv) + w := clitest.StartWithWaiter(t, inv) + pty.ExpectMatch("Delete these tasks:") + pty.WriteLine("yes") + runErr = w.Wait() + outBuf.Write(pty.ReadAll()) + } else { + inv.Stdout = &outBuf + inv.Stderr = &outBuf + runErr = inv.Run() + } + + if tc.wantErr { + require.Error(t, runErr) + } else { + require.NoError(t, runErr) + } + + require.Equal(t, tc.wantDeleteCalls, counters.deleteCalls.Load(), "wrong delete call count") + require.Equal(t, tc.wantNameResolves, counters.nameResolves.Load(), "wrong name resolve count") + + if tc.wantDeletedMessage > 0 { + output := outBuf.String() + require.GreaterOrEqual(t, strings.Count(output, "Deleted task"), tc.wantDeletedMessage) + } + }) + } +}