diff --git a/go.mod b/go.mod index 55b2643834..ccc26396c7 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.19 require ( github.com/alecthomas/assert v1.0.0 + github.com/briandowns/spinner v1.19.0 github.com/c-bata/go-prompt v0.2.5 github.com/chzyer/readline v1.5.1 github.com/containerd/console v1.0.3 diff --git a/go.sum b/go.sum index d89982ecff..e38bd63442 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/alecthomas/colour v0.1.0 h1:nOE9rJm6dsZ66RGWYSFrXw461ZIt9A6+nHgL7FRrD github.com/alecthomas/colour v0.1.0/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48= github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= +github.com/briandowns/spinner v1.19.0/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/c-bata/go-prompt v0.2.5 h1:3zg6PecEywxNn0xiqcXHD96fkbxghD+gdB2tbsYfl+Y= github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw= github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s= @@ -26,6 +28,7 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/etdub/goparsetime v0.0.0-20160315173935-ea17b0ac3318 h1:iguwbR+9xsizl84VMHU47I4OOWYSex1HZRotEoqziWQ= github.com/etdub/goparsetime v0.0.0-20160315173935-ea17b0ac3318/go.mod h1:O/QFFckzvu1KpS1AOuQGgi6ErznEF8nZZVNDDMXlDP4= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= @@ -47,6 +50,7 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kubernetes-client/go-base v0.0.0-20190205182333-3d0e39759d98 h1:ZMIkOkl/Bg5H4EJI7zbjVXAo4rV0QJOGz2U5A0xUmZU= github.com/kubernetes-client/go-base v0.0.0-20190205182333-3d0e39759d98/go.mod h1:HPlr4uJEfrxar3JUY9cmXs3oooPjTLO6nEaEAIt5LI8= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= diff --git a/internal/tasks/loader.go b/internal/tasks/loader.go new file mode 100644 index 0000000000..1d5968ea64 --- /dev/null +++ b/internal/tasks/loader.go @@ -0,0 +1,26 @@ +package tasks + +import ( + "time" + + "github.com/briandowns/spinner" +) + +// Loader will print loading activity to the terminal +type Loader struct { + spinner *spinner.Spinner +} + +func setupLoader() *Loader { + return &Loader{ + spinner.New(spinner.CharSets[11], 100*time.Millisecond), + } +} + +func (l *Loader) Start() { + l.spinner.Start() +} + +func (l *Loader) Stop() { + l.spinner.Stop() +} diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go new file mode 100644 index 0000000000..3590101800 --- /dev/null +++ b/internal/tasks/tasks.go @@ -0,0 +1,130 @@ +package tasks + +import ( + "context" + "fmt" + "os" + "os/signal" +) + +type Task func(ctx context.Context, args interface{}) (nextArgs interface{}, err error) +type TaskWithCleanup[T any] func(ctx context.Context, args interface{}) (nextArgs interface{}, cleanupArgs T, err error) +type Cleanup[T any] func(ctx context.Context, cleanupArgs T) error + +type taskInfo struct { + Name string + function TaskWithCleanup[any] + cleanFunction Cleanup[any] + cleanupArgs interface{} +} + +type Tasks struct { + tasks []taskInfo +} + +func Begin() *Tasks { + return &Tasks{} +} + +// Add a task that does not need cleanup +func (ts *Tasks) Add(name string, task Task) { + ts.tasks = append(ts.tasks, taskInfo{ + Name: name, + function: func(ctx context.Context, i interface{}) (passedData interface{}, cleanUpData interface{}, err error) { + passedData, err = task(ctx, i) + return + }, + }) +} + +// AddWithCleanUp adds a task to the list with a cleanup function in case of fail during tasks execution +func AddWithCleanUp[T any](ts *Tasks, name string, task TaskWithCleanup[T], clean Cleanup[T]) { + ts.tasks = append(ts.tasks, taskInfo{ + Name: name, + function: func(ctx context.Context, args interface{}) (nextArgs interface{}, cleanUpArgs any, err error) { + return task(ctx, args) + }, + cleanFunction: func(ctx context.Context, cleanupArgs any) error { + return clean(ctx, cleanupArgs.(T)) + }, + }) +} + +// setupContext return a contextWithCancel that will cancel on os interrupt (Ctrl-C) +func setupContext(ctx context.Context) (context.Context, func()) { + return signal.NotifyContext(ctx, os.Interrupt) +} + +// Cleanup execute all tasks cleanup function before failed one in reverse order +func (ts *Tasks) Cleanup(ctx context.Context, failed int) { + totalTasks := len(ts.tasks) + loader := setupLoader() + cancelableCtx, cleanCtx := setupContext(ctx) + defer cleanCtx() + + i := failed - 1 + for ; i >= 0; i-- { + task := ts.tasks[i] + + select { + case <-cancelableCtx.Done(): + fmt.Println("cleanup has been cancelled, there may be dangling resources") + return + default: + } + + if task.cleanFunction != nil { + fmt.Printf("[%d/%d] Cleaning task %q\n", i+1, totalTasks, task.Name) + loader.Start() + + err := task.cleanFunction(cancelableCtx, task.cleanupArgs) + if err != nil { + fmt.Printf("task %d failed to cleanup, there may be dangling resources: %s\n", i+1, err.Error()) + } + loader.Stop() + } + } +} + +// Execute tasks with interactive display and cleanup on fail +func (ts *Tasks) Execute(ctx context.Context, data interface{}) (interface{}, error) { + var err error + totalTasks := len(ts.tasks) + loader := setupLoader() + + cancelableCtx, cleanCtx := setupContext(ctx) + defer cleanCtx() + + for i := range ts.tasks { + task := &ts.tasks[i] + fmt.Printf("[%d/%d] %s\n", i+1, totalTasks, task.Name) + loader.Start() + + data, task.cleanupArgs, err = task.function(cancelableCtx, data) + taskIsCancelled := false + select { + case <-cancelableCtx.Done(): + taskIsCancelled = true + default: + } + if err != nil || taskIsCancelled { + loader.Stop() + fmt.Println("task failed, cleaning up created resources") + ts.Cleanup(ctx, i) + return nil, fmt.Errorf("task %d %q failed: %w", i+1, task.Name, err) + } + + select { + case <-ctx.Done(): + loader.Stop() + fmt.Println("context canceled, cleaning up created resources") + ts.Cleanup(ctx, i+1) + return nil, fmt.Errorf("task %d %q failed: context canceled", i+1, task.Name) + default: + } + + loader.Stop() + } + + return data, nil +} diff --git a/internal/tasks/tasks_test.go b/internal/tasks/tasks_test.go new file mode 100644 index 0000000000..b4912d6f85 --- /dev/null +++ b/internal/tasks/tasks_test.go @@ -0,0 +1,96 @@ +package tasks_test + +import ( + "context" + "fmt" + "os" + "runtime" + "testing" + "time" + + "github.com/alecthomas/assert" + "github.com/scaleway/scaleway-cli/v2/internal/tasks" +) + +func TestCleanup(t *testing.T) { + ts := tasks.Begin() + + clean := 0 + + tasks.AddWithCleanUp(ts, "Task 1", func(context.Context, interface{}) (interface{}, string, error) { + return nil, "", nil + }, func(context.Context, string) error { + clean++ + return nil + }) + tasks.AddWithCleanUp(ts, "Task 2", func(context.Context, interface{}) (interface{}, string, error) { + return nil, "", nil + }, func(context.Context, string) error { + clean++ + return nil + }) + tasks.AddWithCleanUp(ts, "Task 3", func(context.Context, interface{}) (interface{}, string, error) { + return nil, "", fmt.Errorf("fail") + }, func(context.Context, string) error { + clean++ + return nil + }) + _, err := ts.Execute(context.Background(), nil) + assert.NotNil(t, err, "Execute should return error after cleanup") + assert.Equal(t, clean, 2, "2 task cleanup should have been executed") +} + +func TestCleanupOnContext(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Cannot send signal on windows") + } + ts := tasks.Begin() + + clean := 0 + ctx := context.Background() + + tasks.AddWithCleanUp(ts, "Task 1", + func(context.Context, interface{}) (interface{}, string, error) { + return nil, "", nil + }, func(context.Context, string) error { + clean++ + return nil + }, + ) + tasks.AddWithCleanUp(ts, "Task 2", + func(context.Context, interface{}) (interface{}, string, error) { + return nil, "", nil + }, func(context.Context, string) error { + clean++ + return nil + }, + ) + tasks.AddWithCleanUp(ts, "Task 3", + func(ctx context.Context, _ interface{}) (interface{}, string, error) { + p, err := os.FindProcess(os.Getpid()) + if err != nil { + return nil, "", err + } + + // Interrupt tasks, as done with Ctrl-C + err = p.Signal(os.Interrupt) + if err != nil { + t.Fatal(err) + } + + select { + case <-time.After(time.Second): + return nil, "", nil + case <-ctx.Done(): + return nil, "", fmt.Errorf("interrupted") + } + }, func(context.Context, string) error { + clean++ + return nil + }, + ) + + _, err := ts.Execute(ctx, nil) + assert.NotNil(t, err, "context should have been interrupted") + assert.Equal(t, clean, 2, "2 task cleanup should have been executed") +}