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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
26 changes: 26 additions & 0 deletions internal/tasks/loader.go
Original file line number Diff line number Diff line change
@@ -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()
}
130 changes: 130 additions & 0 deletions internal/tasks/tasks.go
Original file line number Diff line number Diff line change
@@ -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
}
96 changes: 96 additions & 0 deletions internal/tasks/tasks_test.go
Original file line number Diff line number Diff line change
@@ -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")
}