diff --git a/azure/aci.go b/azure/aci.go index bf7a5109..9d566e2d 100644 --- a/azure/aci.go +++ b/azure/aci.go @@ -19,6 +19,7 @@ import ( "github.com/docker/api/azure/login" "github.com/docker/api/context/store" + "github.com/docker/api/progress" ) const aciDockerUserAgent = "docker-cli" @@ -47,10 +48,17 @@ func createACIContainers(ctx context.Context, aciContext store.AciContext, group } func createOrUpdateACIContainers(ctx context.Context, aciContext store.AciContext, groupDefinition containerinstance.ContainerGroup) error { + w := progress.ContextWriter(ctx) containerGroupsClient, err := getContainerGroupsClient(aciContext.SubscriptionID) if err != nil { return errors.Wrapf(err, "cannot get container group client") } + w.Event(progress.Event{ + ID: *groupDefinition.Name, + Status: progress.Working, + StatusText: "Waiting", + }) + future, err := containerGroupsClient.CreateOrUpdate( ctx, aciContext.ResourceGroup, @@ -61,14 +69,35 @@ func createOrUpdateACIContainers(ctx context.Context, aciContext store.AciContex return err } + w.Event(progress.Event{ + ID: *groupDefinition.Name, + Status: progress.Done, + StatusText: "Created", + }) + for _, c := range *groupDefinition.Containers { + w.Event(progress.Event{ + ID: *c.Name, + Status: progress.Working, + StatusText: "Waiting", + }) + } + err = future.WaitForCompletionRef(ctx, containerGroupsClient.Client) if err != nil { return err } + containerGroup, err := future.Result(containerGroupsClient) if err != nil { return err } + for _, c := range *groupDefinition.Containers { + w.Event(progress.Event{ + ID: *c.Name, + Status: progress.Done, + StatusText: "Done", + }) + } if len(*containerGroup.Containers) > 1 { var commands []string diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index 88f45fd6..18d8af0e 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -35,6 +35,7 @@ import ( "github.com/docker/api/client" "github.com/docker/api/compose" + "github.com/docker/api/progress" ) func upCommand() *cobra.Command { @@ -64,5 +65,7 @@ func runUp(ctx context.Context, opts compose.ProjectOptions) error { return errors.New("compose not implemented in current context") } - return composeService.Up(ctx, opts) + return progress.Run(ctx, func(ctx context.Context) error { + return composeService.Up(ctx, opts) + }) } diff --git a/cli/cmd/run/run.go b/cli/cmd/run/run.go index 6c5e864e..cad343d0 100644 --- a/cli/cmd/run/run.go +++ b/cli/cmd/run/run.go @@ -35,6 +35,7 @@ import ( "github.com/docker/api/cli/options/run" "github.com/docker/api/client" + "github.com/docker/api/progress" ) // Command runs a container @@ -68,10 +69,11 @@ func runRun(ctx context.Context, image string, opts run.Opts) error { return err } - if err = c.ContainerService().Run(ctx, containerConfig); err != nil { - return err + err = progress.Run(ctx, func(ctx context.Context) error { + return c.ContainerService().Run(ctx, containerConfig) + }) + if err == nil { + fmt.Println(opts.Name) } - fmt.Println(opts.Name) - - return nil + return err } diff --git a/go.mod b/go.mod index c601eb70..e1b3ebfa 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/AlecAivazis/survey/v2 v2.0.7 github.com/Azure/azure-sdk-for-go v43.2.0+incompatible github.com/Azure/azure-storage-file-go v0.7.0 - github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect github.com/Azure/go-autorest/autorest v0.10.2 github.com/Azure/go-autorest/autorest/adal v0.8.3 github.com/Azure/go-autorest/autorest/azure/cli v0.3.1 @@ -32,7 +31,8 @@ require ( github.com/google/uuid v1.1.1 github.com/gorilla/mux v1.7.4 // indirect github.com/hashicorp/go-multierror v1.1.0 - github.com/morikuni/aec v1.0.0 // indirect + github.com/moby/term v0.0.0-20200611042045-63b9a826fb74 + github.com/morikuni/aec v1.0.0 github.com/onsi/gomega v1.10.1 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.0.1 // indirect @@ -46,6 +46,7 @@ require ( github.com/tj/survey v2.0.6+incompatible golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be + golang.org/x/sync v0.0.0-20190423024810-112230192c58 golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 // indirect google.golang.org/grpc v1.29.1 google.golang.org/protobuf v1.24.0 diff --git a/go.sum b/go.sum index 1b979ca2..f796aed0 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/AlecAivazis/survey/v2 v2.0.7 h1:+f825XHLse/hWd2tE/V5df04WFGimk34Eyg/z github.com/AlecAivazis/survey/v2 v2.0.7/go.mod h1:mlizQTaPjnR4jcpwRSaSlkbsRfYFEyKgLQvYTzxxiHA= github.com/Azure/azure-pipeline-go v0.2.1 h1:OLBdZJ3yvOn2MezlWvbrBMTEUQC72zAftRZOMdj5HYo= github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= -github.com/Azure/azure-sdk-for-go v43.1.0+incompatible h1:m6EAp2Dmb8/t+ToZ2jtmvdp+JBwsdfSlZuBV31WGLGQ= -github.com/Azure/azure-sdk-for-go v43.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v43.2.0+incompatible h1:H8jfb+wuVlLqyP1Nr6zqapNxqhgwshD5OETJsBO74iY= github.com/Azure/azure-sdk-for-go v43.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-storage-file-go v0.7.0 h1:yWoV0MYwzmoSgWACcVkdPolvAULFPNamcQLpIvS/Et4= @@ -80,6 +78,8 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -204,6 +204,8 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4= github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/term v0.0.0-20200611042045-63b9a826fb74 h1:kvRIeqJNICemq2UFLx8q/Pj+1IRNZS0XPTaMFkuNsvg= +github.com/moby/term v0.0.0-20200611042045-63b9a826fb74/go.mod h1:pJ0Ot5YGdTcMdxnPMyGCfAr6fKXe0g9cDlz16MuFEBE= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -330,6 +332,7 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/progress/plain.go b/progress/plain.go new file mode 100644 index 00000000..8e476807 --- /dev/null +++ b/progress/plain.go @@ -0,0 +1,29 @@ +package progress + +import ( + "context" + "fmt" + "io" +) + +type plainWriter struct { + out io.Writer + done chan bool +} + +func (p *plainWriter) Start(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-p.done: + return nil + } +} + +func (p *plainWriter) Event(e Event) { + fmt.Println(e.ID, e.Text, e.StatusText) +} + +func (p *plainWriter) Stop() { + p.done <- true +} diff --git a/progress/spinner.go b/progress/spinner.go new file mode 100644 index 00000000..8abb9821 --- /dev/null +++ b/progress/spinner.go @@ -0,0 +1,39 @@ +package progress + +import "time" + +type spinner struct { + time time.Time + index int + chars []string + stop bool + done string +} + +func newSpinner() *spinner { + return &spinner{ + index: 0, + time: time.Now(), + chars: []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", + }, + done: "⠿", + } +} + +func (s *spinner) String() string { + if s.stop { + return s.done + } + + d := time.Since(s.time) + if d.Milliseconds() > 100 { + s.index = (s.index + 1) % len(s.chars) + } + + return s.chars[s.index] +} + +func (s *spinner) Stop() { + s.stop = true +} diff --git a/progress/tty.go b/progress/tty.go new file mode 100644 index 00000000..591c2a11 --- /dev/null +++ b/progress/tty.go @@ -0,0 +1,169 @@ +package progress + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/buger/goterm" + "github.com/morikuni/aec" +) + +type ttyWriter struct { + out io.Writer + events map[string]Event + eventIDs []string + repeated bool + numLines int + done chan bool + mtx *sync.RWMutex +} + +func (w *ttyWriter) Start(ctx context.Context) error { + ticker := time.NewTicker(100 * time.Millisecond) + + for { + select { + case <-ctx.Done(): + w.print() + return ctx.Err() + case <-w.done: + w.print() + return nil + case <-ticker.C: + w.print() + } + } +} + +func (w *ttyWriter) Stop() { + w.done <- true +} + +func (w *ttyWriter) Event(e Event) { + w.mtx.Lock() + defer w.mtx.Unlock() + if !contains(w.eventIDs, e.ID) { + w.eventIDs = append(w.eventIDs, e.ID) + } + if _, ok := w.events[e.ID]; ok { + event := w.events[e.ID] + if event.Status != Done && e.Status == Done { + event.stop() + } + event.Status = e.Status + event.Text = e.Text + event.StatusText = e.StatusText + w.events[e.ID] = event + } else { + e.startTime = time.Now() + e.spinner = newSpinner() + w.events[e.ID] = e + } +} + +func (w *ttyWriter) print() { + w.mtx.Lock() + defer w.mtx.Unlock() + if len(w.eventIDs) == 0 { + return + } + terminalWidth := goterm.Width() + b := aec.EmptyBuilder + for i := 0; i <= w.numLines; i++ { + b = b.Up(1) + } + if !w.repeated { + b = b.Down(1) + } + w.repeated = true + fmt.Fprint(w.out, b.Column(0).ANSI) + + // Hide the cursor while we are printing + fmt.Fprint(w.out, aec.Hide) + defer fmt.Fprint(w.out, aec.Show) + + firstLine := fmt.Sprintf("[+] Running %d/%d", numDone(w.events), w.numLines) + if w.numLines != 0 && numDone(w.events) == w.numLines { + firstLine = aec.Apply(firstLine, aec.BlueF) + } + fmt.Fprintln(w.out, firstLine) + + var statusPadding int + for _, v := range w.eventIDs { + l := len(fmt.Sprintf("%s %s", w.events[v].ID, w.events[v].Text)) + if statusPadding < l { + statusPadding = l + } + } + + numLines := 0 + for _, v := range w.eventIDs { + line := lineText(w.events[v], terminalWidth, statusPadding) + // nolint: errcheck + fmt.Fprint(w.out, line) + numLines++ + } + + w.numLines = numLines +} + +func lineText(event Event, terminalWidth, statusPadding int) string { + endTime := time.Now() + if event.Status != Working { + endTime = event.endTime + } + + elapsed := endTime.Sub(event.startTime).Seconds() + + textLen := len(fmt.Sprintf("%s %s", event.ID, event.Text)) + padding := statusPadding - textLen + if padding < 0 { + padding = 0 + } + text := fmt.Sprintf(" %s %s %s%s %s", + event.spinner.String(), + event.ID, + event.Text, + strings.Repeat(" ", padding), + event.StatusText, + ) + timer := fmt.Sprintf("%.1fs\n", elapsed) + o := align(text, timer, terminalWidth) + + color := aec.WhiteF + if event.Status == Done { + color = aec.BlueF + } + if event.Status == Error { + color = aec.RedF + } + + return aec.Apply(o, color) +} + +func numDone(events map[string]Event) int { + i := 0 + for _, e := range events { + if e.Status == Done { + i++ + } + } + return i +} + +func align(l, r string, w int) string { + return fmt.Sprintf("%-[2]*[1]s %[3]s", l, w-len(r)-1, r) +} + +func contains(ar []string, needle string) bool { + for _, v := range ar { + if needle == v { + return true + } + } + return false +} diff --git a/progress/tty_test.go b/progress/tty_test.go new file mode 100644 index 00000000..bb7d31b9 --- /dev/null +++ b/progress/tty_test.go @@ -0,0 +1,37 @@ +package progress + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLineText(t *testing.T) { + now := time.Now() + ev := Event{ + ID: "id", + Text: "Text", + Status: Working, + StatusText: "Status", + endTime: now, + startTime: now, + spinner: &spinner{ + chars: []string{"."}, + }, + } + + lineWidth := len(fmt.Sprintf("%s %s", ev.ID, ev.Text)) + + out := lineText(ev, 50, lineWidth) + assert.Equal(t, "\x1b[37m . id Text Status 0.0s\n\x1b[0m", out) + + ev.Status = Done + out = lineText(ev, 50, lineWidth) + assert.Equal(t, "\x1b[34m . id Text Status 0.0s\n\x1b[0m", out) + + ev.Status = Error + out = lineText(ev, 50, lineWidth) + assert.Equal(t, "\x1b[31m . id Text Status 0.0s\n\x1b[0m", out) +} diff --git a/progress/writer.go b/progress/writer.go new file mode 100644 index 00000000..a5b8e1cd --- /dev/null +++ b/progress/writer.go @@ -0,0 +1,112 @@ +package progress + +import ( + "context" + "os" + "sync" + "time" + + "github.com/containerd/console" + "github.com/moby/term" + "golang.org/x/sync/errgroup" +) + +// EventStatus indicates the status of an action +type EventStatus int + +const ( + // Working means that the current task is working + Working EventStatus = iota + // Done means that the current task is done + Done + // Error means that the current task has errored + Error +) + +// Event reprensents a progress event +type Event struct { + ID string + Text string + Status EventStatus + StatusText string + Done bool + + startTime time.Time + endTime time.Time + spinner *spinner +} + +func (e *Event) stop() { + e.endTime = time.Now() + e.spinner.Stop() +} + +// Writer can write multiple progress events +type Writer interface { + Start(context.Context) error + Stop() + Event(Event) +} + +type writerKey struct{} + +// WithContextWriter adds the writer to the context +func WithContextWriter(ctx context.Context, writer Writer) context.Context { + return context.WithValue(ctx, writerKey{}, writer) +} + +// ContextWriter returns the writer from the context +func ContextWriter(ctx context.Context) Writer { + s, _ := ctx.Value(writerKey{}).(Writer) + return s +} + +type progressFunc func(context.Context) error + +// Run will run a writer and the progress function +// in parallel +func Run(ctx context.Context, pf progressFunc) error { + eg, _ := errgroup.WithContext(ctx) + w, err := NewWriter(os.Stderr) + if err != nil { + return err + } + eg.Go(func() error { + return w.Start(context.Background()) + }) + + ctx = WithContextWriter(ctx, w) + + eg.Go(func() error { + defer w.Stop() + return pf(ctx) + }) + + return eg.Wait() +} + +// NewWriter returns a new multi-progress writer +func NewWriter(out console.File) (Writer, error) { + _, isTerminal := term.GetFdInfo(out) + + if isTerminal { + con, err := console.ConsoleFromFile(out) + if err != nil { + return nil, err + } + + return &ttyWriter{ + out: con, + eventIDs: []string{}, + events: map[string]Event{}, + repeated: false, + done: make(chan bool), + mtx: &sync.RWMutex{}, + }, nil + } + + return &plainWriter{ + out: out, + done: make(chan bool), + }, nil +}