From 3d28b10954c3223ce2dfd0bd92b3bb892ba931f3 Mon Sep 17 00:00:00 2001 From: Sam Gunaratne <385176+Samze@users.noreply.github.com> Date: Tue, 26 Nov 2024 16:05:47 -0700 Subject: [PATCH] Add cf task command (#3315) --- command/common/command_list_v7.go | 1 + command/common/internal/help_all_display.go | 2 +- command/flag/arguments.go | 5 + command/v7/run_task_command.go | 2 +- command/v7/task_command.go | 71 +++++ command/v7/task_command_test.go | 282 ++++++++++++++++++ command/v7/tasks_command.go | 2 +- command/v7/terminate_task_command.go | 2 +- .../v7/isolated/run_task_command_test.go | 2 +- integration/v7/isolated/task_command_test.go | 127 ++++++++ integration/v7/isolated/tasks_command_test.go | 2 +- resources/task_resource.go | 7 + 12 files changed, 499 insertions(+), 6 deletions(-) create mode 100644 command/v7/task_command.go create mode 100644 command/v7/task_command_test.go create mode 100644 integration/v7/isolated/task_command_test.go diff --git a/command/common/command_list_v7.go b/command/common/command_list_v7.go index f752d224aed..bba46d9fa91 100644 --- a/command/common/command_list_v7.go +++ b/command/common/command_list_v7.go @@ -172,6 +172,7 @@ type commandList struct { Start v7.StartCommand `command:"start" alias:"st" description:"Start an app"` Stop v7.StopCommand `command:"stop" alias:"sp" description:"Stop an app"` Target v7.TargetCommand `command:"target" alias:"t" description:"Set or view the targeted org or space"` + Task v7.TaskCommand `command:"task" description:"Display a task of an app"` Tasks v7.TasksCommand `command:"tasks" description:"List tasks of an app"` TerminateTask v7.TerminateTaskCommand `command:"terminate-task" description:"Terminate a running task of an app"` MoveRoute v7.MoveRouteCommand `command:"move-route" description:"Assign a route to a different space"` diff --git a/command/common/internal/help_all_display.go b/command/common/internal/help_all_display.go index 9090f70f46f..b3a8ca198af 100644 --- a/command/common/internal/help_all_display.go +++ b/command/common/internal/help_all_display.go @@ -15,7 +15,7 @@ var HelpCategoryList = []HelpCategory{ {"push", "scale", "delete", "rename"}, {"cancel-deployment", "continue-deployment"}, {"start", "stop", "restart", "stage-package", "restage", "restart-app-instance"}, - {"run-task", "tasks", "terminate-task"}, + {"run-task", "task", "tasks", "terminate-task"}, {"packages", "create-package"}, {"revisions", "rollback"}, {"droplets", "set-droplet", "download-droplet"}, diff --git a/command/flag/arguments.go b/command/flag/arguments.go index 2ede06676f1..9e3d60263f8 100644 --- a/command/flag/arguments.go +++ b/command/flag/arguments.go @@ -408,3 +408,8 @@ type RemoveNetworkPolicyArgsV7 struct { SourceApp string `positional-arg-name:"SOURCE_APP" required:"true" description:"The source app"` DestApp string `positional-arg-name:"DESTINATION_APP" required:"true" description:"The destination app"` } + +type TaskArgs struct { + AppName string `positional-arg-name:"APP_NAME" required:"true" description:"The application name"` + TaskID int `positional-arg-name:"TASK_ID" required:"true" description:"The Task ID for the application"` +} diff --git a/command/v7/run_task_command.go b/command/v7/run_task_command.go index 61defab7818..a8f6e96b153 100644 --- a/command/v7/run_task_command.go +++ b/command/v7/run_task_command.go @@ -19,7 +19,7 @@ type RunTaskCommand struct { Process string `long:"process" description:"Process type to use as a template for command, memory, and disk for the created task."` Wait bool `long:"wait" short:"w" description:"Wait for the task to complete before exiting"` usage interface{} `usage:"CF_NAME run-task APP_NAME [--command COMMAND] [-k DISK] [-m MEMORY] [-l LOG_RATE_LIMIT] [--name TASK_NAME] [--process PROCESS_TYPE]\n\nTIP:\n Use 'cf logs' to display the logs of the app and all its tasks. If your task name is unique, grep this command's output for the task name to view task-specific logs.\n\nEXAMPLES:\n CF_NAME run-task my-app --command \"bundle exec rake db:migrate\" --name migrate\n\n CF_NAME run-task my-app --process batch_job\n\n CF_NAME run-task my-app"` - relatedCommands interface{} `related_commands:"logs, tasks, terminate-task"` + relatedCommands interface{} `related_commands:"logs, tasks, task, terminate-task"` } func (cmd RunTaskCommand) Execute(args []string) error { diff --git a/command/v7/task_command.go b/command/v7/task_command.go new file mode 100644 index 00000000000..44fe05ce3d8 --- /dev/null +++ b/command/v7/task_command.go @@ -0,0 +1,71 @@ +package v7 + +import ( + "strconv" + + "code.cloudfoundry.org/cli/command/flag" + "code.cloudfoundry.org/cli/util/ui" +) + +type TaskCommand struct { + BaseCommand + + RequiredArgs flag.TaskArgs `positional-args:"yes"` + usage interface{} `usage:"CF_NAME task APP_NAME TASK_ID"` + relatedCommands interface{} `related_commands:"apps, logs, run-task, tasks, terminate-task"` +} + +func (cmd TaskCommand) Execute(args []string) error { + err := cmd.SharedActor.CheckTarget(true, true) + if err != nil { + return err + } + + space := cmd.Config.TargetedSpace() + + user, err := cmd.Actor.GetCurrentUser() + if err != nil { + return err + } + + application, warnings, err := cmd.Actor.GetApplicationByNameAndSpace(cmd.RequiredArgs.AppName, space.GUID) + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + cmd.UI.DisplayTextWithFlavor("Getting task {{.TaskID}} for app {{.AppName}} in org {{.OrgName}} / space {{.SpaceName}} as {{.CurrentUser}}...", map[string]interface{}{ + "TaskID": cmd.RequiredArgs.TaskID, + "AppName": cmd.RequiredArgs.AppName, + "OrgName": cmd.Config.TargetedOrganization().Name, + "SpaceName": space.Name, + "CurrentUser": user.Name, + }) + cmd.UI.DisplayNewline() + + task, warnings, err := cmd.Actor.GetTaskBySequenceIDAndApplication(cmd.RequiredArgs.TaskID, application.GUID) + cmd.UI.DisplayWarnings(warnings) + if err != nil { + return err + } + + if task.Command == "" { + task.Command = "[hidden]" + } + + table := [][]string{ + {cmd.UI.TranslateText("id:"), strconv.FormatInt(task.SequenceID, 10)}, + {cmd.UI.TranslateText("name:"), task.Name}, + {cmd.UI.TranslateText("state:"), string(task.State)}, + {cmd.UI.TranslateText("start time:"), task.CreatedAt}, + {cmd.UI.TranslateText("command:"), task.Command}, + {cmd.UI.TranslateText("memory in mb:"), strconv.FormatUint(task.MemoryInMB, 10)}, + {cmd.UI.TranslateText("disk in mb:"), strconv.FormatUint(task.DiskInMB, 10)}, + {cmd.UI.TranslateText("log rate limit:"), strconv.Itoa(task.LogRateLimitInBPS)}, + {cmd.UI.TranslateText("failure reason:"), task.Result.FailureReason}, + } + + cmd.UI.DisplayKeyValueTable("", table, ui.DefaultTableSpacePadding) + + return nil +} diff --git a/command/v7/task_command_test.go b/command/v7/task_command_test.go new file mode 100644 index 00000000000..9d2ccdc3e6c --- /dev/null +++ b/command/v7/task_command_test.go @@ -0,0 +1,282 @@ +package v7_test + +import ( + "errors" + + "code.cloudfoundry.org/cli/actor/actionerror" + "code.cloudfoundry.org/cli/actor/v7action" + "code.cloudfoundry.org/cli/api/cloudcontroller/ccerror" + "code.cloudfoundry.org/cli/api/cloudcontroller/ccv3/constant" + "code.cloudfoundry.org/cli/command/commandfakes" + . "code.cloudfoundry.org/cli/command/v7" + "code.cloudfoundry.org/cli/command/v7/v7fakes" + "code.cloudfoundry.org/cli/resources" + "code.cloudfoundry.org/cli/util/configv3" + "code.cloudfoundry.org/cli/util/ui" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gbytes" +) + +var _ = Describe("task Command", func() { + var ( + cmd TaskCommand + testUI *ui.UI + fakeConfig *commandfakes.FakeConfig + fakeSharedActor *commandfakes.FakeSharedActor + fakeActor *v7fakes.FakeActor + binaryName string + executeErr error + ) + + BeforeEach(func() { + testUI = ui.NewTestUI(nil, NewBuffer(), NewBuffer()) + fakeConfig = new(commandfakes.FakeConfig) + fakeSharedActor = new(commandfakes.FakeSharedActor) + fakeActor = new(v7fakes.FakeActor) + + cmd = TaskCommand{ + BaseCommand: BaseCommand{ + UI: testUI, + Config: fakeConfig, + SharedActor: fakeSharedActor, + Actor: fakeActor, + }, + } + + cmd.RequiredArgs.AppName = "some-app-name" + cmd.RequiredArgs.TaskID = 3 + + binaryName = "faceman" + fakeConfig.BinaryNameReturns(binaryName) + }) + + JustBeforeEach(func() { + executeErr = cmd.Execute(nil) + }) + + When("checking target fails", func() { + BeforeEach(func() { + fakeSharedActor.CheckTargetReturns(actionerror.NotLoggedInError{BinaryName: binaryName}) + }) + + It("returns an error", func() { + Expect(executeErr).To(MatchError(actionerror.NotLoggedInError{BinaryName: binaryName})) + + Expect(fakeSharedActor.CheckTargetCallCount()).To(Equal(1)) + checkTargetedOrg, checkTargetedSpace := fakeSharedActor.CheckTargetArgsForCall(0) + Expect(checkTargetedOrg).To(BeTrue()) + Expect(checkTargetedSpace).To(BeTrue()) + }) + }) + + When("the user is logged in, and a space and org are targeted", func() { + BeforeEach(func() { + fakeConfig.HasTargetedOrganizationReturns(true) + fakeConfig.TargetedOrganizationReturns(configv3.Organization{ + GUID: "some-org-guid", + Name: "some-org", + }) + fakeConfig.HasTargetedSpaceReturns(true) + fakeConfig.TargetedSpaceReturns(configv3.Space{ + GUID: "some-space-guid", + Name: "some-space", + }) + }) + + When("getting the current user returns an error", func() { + var expectedErr error + + BeforeEach(func() { + expectedErr = errors.New("get current user error") + fakeActor.GetCurrentUserReturns( + configv3.User{}, + expectedErr) + }) + + It("returns the error", func() { + Expect(executeErr).To(MatchError(expectedErr)) + }) + }) + + When("getting the current user does not return an error", func() { + BeforeEach(func() { + fakeActor.GetCurrentUserReturns( + configv3.User{Name: "some-user"}, + nil) + }) + + When("provided a valid application name", func() { + BeforeEach(func() { + fakeActor.GetApplicationByNameAndSpaceReturns( + resources.Application{GUID: "some-app-guid"}, + v7action.Warnings{"get-application-warning-1", "get-application-warning-2"}, + nil) + fakeActor.GetTaskBySequenceIDAndApplicationReturns( + resources.Task{ + GUID: "task-3-guid", + SequenceID: 3, + Name: "task-3", + State: constant.TaskRunning, + CreatedAt: "2016-11-08T22:26:02Z", + Command: "some-command", + MemoryInMB: 100, + DiskInMB: 200, + LogRateLimitInBPS: 300, + Result: &resources.TaskResult{ + FailureReason: "some failure message", + }, + }, + v7action.Warnings{"get-task-warning-1"}, + nil) + }) + + It("outputs the task and all warnings", func() { + Expect(executeErr).ToNot(HaveOccurred()) + + Expect(fakeActor.GetApplicationByNameAndSpaceCallCount()).To(Equal(1)) + appName, spaceGUID := fakeActor.GetApplicationByNameAndSpaceArgsForCall(0) + Expect(appName).To(Equal("some-app-name")) + Expect(spaceGUID).To(Equal("some-space-guid")) + + Expect(fakeActor.GetTaskBySequenceIDAndApplicationCallCount()).To(Equal(1)) + taskId, appGuid := fakeActor.GetTaskBySequenceIDAndApplicationArgsForCall(0) + Expect(taskId).To(Equal(3)) + Expect(appGuid).To(Equal("some-app-guid")) + + Expect(testUI.Out).To(Say("Getting task 3 for app some-app-name in org some-org / space some-space as some-user...")) + + Expect(testUI.Out).To(Say(`id:\s+3`)) + Expect(testUI.Out).To(Say(`name:\s+task-3`)) + Expect(testUI.Out).To(Say(`state:\s+RUNNING`)) + Expect(testUI.Out).To(Say(`start time:\s+2016-11-08T22:26:02Z`)) + Expect(testUI.Out).To(Say(`command:\s+some-command`)) + Expect(testUI.Out).To(Say(`memory in mb:\s+100`)) + Expect(testUI.Out).To(Say(`disk in mb:\s+200`)) + Expect(testUI.Out).To(Say(`log rate limit:\s+300`)) + Expect(testUI.Out).To(Say(`failure reason:\s+some failure message`)) + + Expect(testUI.Err).To(Say("get-application-warning-1")) + Expect(testUI.Err).To(Say("get-application-warning-2")) + Expect(testUI.Err).To(Say("get-task-warning-1")) + }) + + When("the API does not return a command", func() { + BeforeEach(func() { + fakeActor.GetTaskBySequenceIDAndApplicationReturns( + resources.Task{ + GUID: "task-3-guid", + SequenceID: 3, + Name: "task-3", + State: constant.TaskRunning, + CreatedAt: "2016-11-08T22:26:02Z", + Command: "", + MemoryInMB: 100, + DiskInMB: 200, + LogRateLimitInBPS: 300, + Result: &resources.TaskResult{ + FailureReason: "some failure message", + }, + }, + v7action.Warnings{"get-task-warning-1"}, + nil) + }) + It("displays [hidden] for the command", func() { + Expect(executeErr).ToNot(HaveOccurred()) + Expect(testUI.Out).To(Say(`.*command:\s+\[hidden\]`)) + }) + }) + }) + + When("there are errors", func() { + When("the error is translatable", func() { + When("getting the application returns the error", func() { + var ( + returnedErr error + expectedErr error + ) + + BeforeEach(func() { + expectedErr = errors.New("request-error") + returnedErr = ccerror.RequestError{Err: expectedErr} + fakeActor.GetApplicationByNameAndSpaceReturns( + resources.Application{GUID: "some-app-guid"}, + nil, + returnedErr) + }) + + It("returns a translatable error", func() { + Expect(executeErr).To(MatchError(ccerror.RequestError{Err: expectedErr})) + }) + }) + + When("getting the app task returns the error", func() { + var returnedErr error + + BeforeEach(func() { + returnedErr = ccerror.UnverifiedServerError{URL: "some-url"} + fakeActor.GetApplicationByNameAndSpaceReturns( + resources.Application{GUID: "some-app-guid"}, + nil, + nil) + fakeActor.GetTaskBySequenceIDAndApplicationReturns( + resources.Task{}, + nil, + returnedErr) + }) + + It("returns a translatable error", func() { + Expect(executeErr).To(MatchError(returnedErr)) + }) + }) + }) + + When("the error is not translatable", func() { + When("getting the app returns the error", func() { + var expectedErr error + + BeforeEach(func() { + expectedErr = errors.New("bananapants") + fakeActor.GetApplicationByNameAndSpaceReturns( + resources.Application{GUID: "some-app-guid"}, + v7action.Warnings{"get-application-warning-1", "get-application-warning-2"}, + expectedErr) + }) + + It("return the error and outputs all warnings", func() { + Expect(executeErr).To(MatchError(expectedErr)) + + Expect(testUI.Err).To(Say("get-application-warning-1")) + Expect(testUI.Err).To(Say("get-application-warning-2")) + }) + }) + + When("getting the app task returns the error", func() { + var expectedErr error + + BeforeEach(func() { + expectedErr = errors.New("bananapants??") + fakeActor.GetApplicationByNameAndSpaceReturns( + resources.Application{GUID: "some-app-guid"}, + v7action.Warnings{"get-application-warning-1", "get-application-warning-2"}, + nil) + fakeActor.GetTaskBySequenceIDAndApplicationReturns( + resources.Task{}, + v7action.Warnings{"get-task-warning-1", "get-task-warning-2"}, + expectedErr) + }) + + It("returns the error and outputs all warnings", func() { + Expect(executeErr).To(MatchError(expectedErr)) + + Expect(testUI.Err).To(Say("get-application-warning-1")) + Expect(testUI.Err).To(Say("get-application-warning-2")) + Expect(testUI.Err).To(Say("get-task-warning-1")) + Expect(testUI.Err).To(Say("get-task-warning-2")) + }) + }) + }) + }) + }) + }) +}) diff --git a/command/v7/tasks_command.go b/command/v7/tasks_command.go index 9c3cf8fcdf6..bf5c7c195ec 100644 --- a/command/v7/tasks_command.go +++ b/command/v7/tasks_command.go @@ -14,7 +14,7 @@ type TasksCommand struct { RequiredArgs flag.AppName `positional-args:"yes"` usage interface{} `usage:"CF_NAME tasks APP_NAME"` - relatedCommands interface{} `related_commands:"apps, logs, run-task, terminate-task"` + relatedCommands interface{} `related_commands:"apps, logs, run-task, task, terminate-task"` } func (cmd TasksCommand) Execute(args []string) error { diff --git a/command/v7/terminate_task_command.go b/command/v7/terminate_task_command.go index 0099980f16c..4738b7255af 100644 --- a/command/v7/terminate_task_command.go +++ b/command/v7/terminate_task_command.go @@ -10,7 +10,7 @@ type TerminateTaskCommand struct { RequiredArgs flag.TerminateTaskArgs `positional-args:"yes"` usage interface{} `usage:"CF_NAME terminate-task APP_NAME TASK_ID\n\nEXAMPLES:\n CF_NAME terminate-task my-app 3"` - relatedCommands interface{} `related_commands:"tasks"` + relatedCommands interface{} `related_commands:"tasks, task"` } func (cmd TerminateTaskCommand) Execute(args []string) error { diff --git a/integration/v7/isolated/run_task_command_test.go b/integration/v7/isolated/run_task_command_test.go index cbbdd446eec..6cbbc7a0080 100644 --- a/integration/v7/isolated/run_task_command_test.go +++ b/integration/v7/isolated/run_task_command_test.go @@ -35,7 +35,7 @@ var _ = Describe("run-task command", func() { Expect(session).To(Say(` --name Name to give the task \(generated if omitted\)`)) Expect(session).To(Say(` --process Process type to use as a template for command, memory, and disk for the created task`)) Expect(session).To(Say("SEE ALSO:")) - Expect(session).To(Say(" logs, tasks, terminate-task")) + Expect(session).To(Say(" logs, tasks, task, terminate-task")) }) }) diff --git a/integration/v7/isolated/task_command_test.go b/integration/v7/isolated/task_command_test.go new file mode 100644 index 00000000000..079e1acda47 --- /dev/null +++ b/integration/v7/isolated/task_command_test.go @@ -0,0 +1,127 @@ +package isolated + +import ( + "fmt" + + "code.cloudfoundry.org/cli/integration/helpers" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gbytes" + . "github.com/onsi/gomega/gexec" +) + +var _ = Describe("task command", func() { + var ( + appName string + ) + + BeforeEach(func() { + appName = helpers.PrefixedRandomName("APP") + }) + + When("--help flag is set", func() { + It("Displays command usage to output", func() { + session := helpers.CF("task", "--help") + Eventually(session).Should(Say("NAME:")) + Eventually(session).Should(Say(" task - Display a task of an app")) + Eventually(session).Should(Say("USAGE:")) + Eventually(session).Should(Say(" cf task APP_NAME TASK_ID")) + Eventually(session).Should(Say("SEE ALSO:")) + Eventually(session).Should(Say(" apps, logs, run-task, tasks, terminate-task")) + Eventually(session).Should(Exit(0)) + }) + }) + + When("the environment is not setup correctly", func() { + It("fails with the appropriate errors", func() { + helpers.CheckEnvironmentTargetedCorrectly(true, true, ReadOnlyOrg, "task", "app-name", "1") + }) + }) + + When("the environment is setup correctly", func() { + var ( + orgName string + spaceName string + ) + + BeforeEach(func() { + orgName = helpers.NewOrgName() + spaceName = helpers.NewSpaceName() + + helpers.SetupCF(orgName, spaceName) + }) + + AfterEach(func() { + helpers.LoginCF() + helpers.QuickDeleteOrg(orgName) + }) + + When("the application does not exist", func() { + It("fails and outputs an app not found message", func() { + session := helpers.CF("task", appName, "1") + Eventually(session).Should(Say("FAILED")) + Eventually(session.Err).Should(Say(fmt.Sprintf("App '%s' not found", appName))) + Eventually(session).Should(Exit(1)) + }) + }) + + When("the application exists", func() { + BeforeEach(func() { + helpers.WithHelloWorldApp(func(appDir string) { + Eventually(helpers.CF("push", appName, "-p", appDir, "-b", "staticfile_buildpack")).Should(Exit(0)) + }) + }) + + When("the application does not have the associated task", func() { + It("displays an erro", func() { + session := helpers.CF("task", appName, "1000") + Eventually(session.Err).Should(Say(`Task sequence ID 1000 not found`)) + Eventually(session).Should(Exit(1)) + }) + }) + + When("the application has associated tasks", func() { + BeforeEach(func() { + Eventually(helpers.CF("run-task", appName, "--command", "echo hello world")).Should(Exit(0)) + }) + + It("displays the task", func() { + session := helpers.CF("task", appName, "1") + userName, _ := helpers.GetCredentials() + Eventually(session).Should(Say(fmt.Sprintf("Getting task 1 for app %s in org %s / space %s as %s...", appName, orgName, spaceName, userName))) + + Eventually(session).Should(Say(`id:\s+1`)) + Eventually(session).Should(Say(`name:\s+`)) + Eventually(session).Should(Say(`state:\s+`)) + Eventually(session).Should(Say(`command:\s+echo hello world`)) + + Eventually(session).Should(Exit(0)) + }) + + When("the logged in user does not have authorization to see task commands", func() { + var user string + + BeforeEach(func() { + user = helpers.NewUsername() + password := helpers.NewPassword() + Eventually(helpers.CF("create-user", user, password)).Should(Exit(0)) + Eventually(helpers.CF("set-space-role", user, orgName, spaceName, "SpaceAuditor")).Should(Exit(0)) + helpers.LogoutCF() + env := map[string]string{ + "CF_USERNAME": user, + "CF_PASSWORD": password, + } + Eventually(helpers.CFWithEnv(env, "auth")).Should(Exit(0)) + Eventually(helpers.CF("target", "-o", orgName, "-s", spaceName)).Should(Exit(0)) + }) + + It("displays [hidden] as tasks command", func() { + session := helpers.CF("task", appName, "1") + Eventually(session).Should(Say(`.*command:\s+\[hidden\]`)) + Eventually(session).Should(Exit(0)) + }) + }) + }) + }) + }) +}) diff --git a/integration/v7/isolated/tasks_command_test.go b/integration/v7/isolated/tasks_command_test.go index 8d1bb2d23da..55b37b2e599 100644 --- a/integration/v7/isolated/tasks_command_test.go +++ b/integration/v7/isolated/tasks_command_test.go @@ -27,7 +27,7 @@ var _ = Describe("tasks command", func() { Eventually(session).Should(Say("USAGE:")) Eventually(session).Should(Say(" cf tasks APP_NAME")) Eventually(session).Should(Say("SEE ALSO:")) - Eventually(session).Should(Say(" apps, logs, run-task, terminate-task")) + Eventually(session).Should(Say(" apps, logs, run-task, task, terminate-task")) Eventually(session).Should(Exit(0)) }) }) diff --git a/resources/task_resource.go b/resources/task_resource.go index 62f1f500ed4..6a0184ff865 100644 --- a/resources/task_resource.go +++ b/resources/task_resource.go @@ -32,6 +32,9 @@ type Task struct { // Using a pointer so that it can be set to nil to prevent // json serialization when no template is used Template *TaskTemplate `json:"template,omitempty"` + + // Result contains the task result + Result *TaskResult `json:"result,omitempty"` } type TaskTemplate struct { @@ -41,3 +44,7 @@ type TaskTemplate struct { type TaskProcessTemplate struct { Guid string `json:"guid,omitempty"` } + +type TaskResult struct { + FailureReason string `json:"failure_reason,omitempty"` +}