Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions cmd/workflow/resume.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package workflow

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"

"github.com/jedib0t/go-pretty/v6/table"
khhttp "github.com/keeperhub/cli/internal/http"
"github.com/keeperhub/cli/internal/output"
"github.com/keeperhub/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)

func NewResumeCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "resume <workflow-id>",
Aliases: []string{"activate"},
Short: "Resume a paused workflow",
Args: cobra.ExactArgs(1),
Example: ` # Resume a workflow (will prompt for confirmation)
kh wf resume abc123

# Resume without prompting
kh wf resume abc123 --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
workflowID := args[0]

yes, err := cmd.Flags().GetBool("yes")
if err != nil {
// --yes not defined locally; check parent persistent flags
yes = false
}

// Resuming restarts automation that can submit transactions, so it
// is confirmed like pause rather than treated as a safe no-op.
if !yes && f.IOStreams.IsTerminal() {
fmt.Fprintf(f.IOStreams.Out, "Resume workflow %s? (y/N) ", workflowID)
scanner := bufio.NewScanner(f.IOStreams.In)
if scanner.Scan() {
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
if answer != "y" && answer != "yes" {
return cmdutil.CancelError{Err: fmt.Errorf("resume cancelled")}
}
}
}
// Non-TTY or --yes: proceed without prompting

client, err := f.HTTPClient()
if err != nil {
return err
}
cfg, err := f.Config()
if err != nil {
return err
}
host := cmdutil.ResolveHost(cmd, cfg)

bodyBytes, err := json.Marshal(map[string]bool{"enabled": true})
if err != nil {
return err
}

url := khhttp.BuildBaseURL(host) + "/api/workflows/" + workflowID
req, err := client.NewRequest(http.MethodPatch, url, bytes.NewReader(bodyBytes))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")

resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return khhttp.NewAPIError(resp)
}

var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decoding resume response: %w", err)
}

p := output.NewPrinter(f.IOStreams, cmd)
return p.PrintData(result, func(tw table.Writer) {
fmt.Fprintf(f.IOStreams.Out, "Workflow %s resumed\n", workflowID)
tw.Render()
})
},
}

cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")

return cmd
}
124 changes: 124 additions & 0 deletions cmd/workflow/resume_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package workflow_test

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/keeperhub/cli/cmd/workflow"
"github.com/keeperhub/cli/internal/config"
khhttp "github.com/keeperhub/cli/internal/http"
"github.com/keeperhub/cli/pkg/cmdutil"
"github.com/keeperhub/cli/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func newResumeFactory(ios *iostreams.IOStreams, svr *httptest.Server) *cmdutil.Factory {
return &cmdutil.Factory{
AppVersion: "1.0.0",
IOStreams: ios,
Config: func() (config.Config, error) {
return config.Config{DefaultHost: svr.URL}, nil
},
HTTPClient: func() (*khhttp.Client, error) {
return khhttp.NewClient(khhttp.ClientOptions{
AppVersion: "1.0.0",
IOStreams: ios,
}), nil
},
}
}

func runResumeViaParent(f *cmdutil.Factory, args []string) error {
parent := workflow.NewWorkflowCmd(f)
parent.SetArgs(append([]string{"resume"}, args...))
return parent.Execute()
}

func TestResumeSendsPATCHWithEnabledTrue(t *testing.T) {
var receivedMethod, receivedPath string
var receivedBody map[string]interface{}
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedMethod = r.Method
receivedPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"wf-abc","enabled":true}`))
}))
defer svr.Close()

// Non-TTY: auto-skips confirmation
ios, outBuf, _, _ := iostreams.Test()
f := newResumeFactory(ios, svr)

err := runResumeViaParent(f, []string{"wf-abc"})

require.NoError(t, err)
assert.Equal(t, "PATCH", receivedMethod)
assert.Equal(t, "/api/workflows/wf-abc", receivedPath)
// The inverse of pause: this is the assertion that distinguishes the two.
assert.Equal(t, true, receivedBody["enabled"])
assert.Contains(t, outBuf.String(), "resumed")
}

func TestResumeYesFlagSkipsConfirmation(t *testing.T) {
var patchCalled bool
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PATCH" {
patchCalled = true
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"wf-abc","enabled":true}`))
}))
defer svr.Close()

ios, _, _, _ := iostreams.Test()
f := newResumeFactory(ios, svr)

err := runResumeViaParent(f, []string{"wf-abc", "--yes"})

require.NoError(t, err)
assert.True(t, patchCalled, "PATCH should have been called")
}

func TestResumeActivateAlias(t *testing.T) {
var patchCalled bool
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PATCH" {
patchCalled = true
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"wf-abc","enabled":true}`))
}))
defer svr.Close()

ios, _, _, _ := iostreams.Test()
f := newResumeFactory(ios, svr)

parent := workflow.NewWorkflowCmd(f)
parent.SetArgs([]string{"activate", "wf-abc", "--yes"})

require.NoError(t, parent.Execute())
assert.True(t, patchCalled, "PATCH should have been called via the activate alias")
}

func TestResumeAPIErrorSurfaces(t *testing.T) {
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"Workflow not found"}`))
}))
defer svr.Close()

ios, _, _, _ := iostreams.Test()
f := newResumeFactory(ios, svr)

err := runResumeViaParent(f, []string{"wf-missing", "--yes"})

require.Error(t, err)
}
1 change: 1 addition & 0 deletions cmd/workflow/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func NewWorkflowCmd(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewUpdateCmd(f))
cmd.AddCommand(NewGoLiveCmd(f))
cmd.AddCommand(NewPauseCmd(f))
cmd.AddCommand(NewResumeCmd(f))

return cmd
}
4 changes: 2 additions & 2 deletions cmd/workflow/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ func newTestFactory() *cmdutil.Factory {
}
}

func TestWorkflowCmdHas8Subcommands(t *testing.T) {
func TestWorkflowCmdHas9Subcommands(t *testing.T) {
f := newTestFactory()
wfCmd := workflow.NewWorkflowCmd(f)
cmds := wfCmd.Commands()
assert.Equal(t, 8, len(cmds), "expected 8 subcommands: list, run, get, go-live, pause, create, delete, update")
assert.Equal(t, 9, len(cmds), "expected 9 subcommands: list, run, get, go-live, pause, resume, create, delete, update")
}

func TestWorkflowCmdHasAlias(t *testing.T) {
Expand Down
Loading