Move compose v2 implementation under pkg/compose with dependencies

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2021-06-15 09:57:38 +02:00
parent e2ea24ceb7
commit 49e7f2d45d
93 changed files with 261 additions and 331 deletions

133
pkg/progress/event.go Normal file
View file

@ -0,0 +1,133 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package progress
import "time"
// 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 represents a progress event.
type Event struct {
ID string
ParentID string
Text string
Status EventStatus
StatusText string
startTime time.Time
endTime time.Time
spinner *spinner
}
// ErrorMessageEvent creates a new Error Event with message
func ErrorMessageEvent(ID string, msg string) Event {
return NewEvent(ID, Error, msg)
}
// ErrorEvent creates a new Error Event
func ErrorEvent(ID string) Event {
return NewEvent(ID, Error, "Error")
}
// CreatingEvent creates a new Create in progress Event
func CreatingEvent(ID string) Event {
return NewEvent(ID, Working, "Creating")
}
// StartingEvent creates a new Starting in progress Event
func StartingEvent(ID string) Event {
return NewEvent(ID, Working, "Starting")
}
// StartedEvent creates a new Started in progress Event
func StartedEvent(ID string) Event {
return NewEvent(ID, Done, "Started")
}
// RestartingEvent creates a new Restarting in progress Event
func RestartingEvent(ID string) Event {
return NewEvent(ID, Working, "Restarting")
}
// RestartedEvent creates a new Restarted in progress Event
func RestartedEvent(ID string) Event {
return NewEvent(ID, Done, "Restarted")
}
// RunningEvent creates a new Running in progress Event
func RunningEvent(ID string) Event {
return NewEvent(ID, Done, "Running")
}
// CreatedEvent creates a new Created (done) Event
func CreatedEvent(ID string) Event {
return NewEvent(ID, Done, "Created")
}
// StoppingEvent creates a new Stopping in progress Event
func StoppingEvent(ID string) Event {
return NewEvent(ID, Working, "Stopping")
}
// StoppedEvent creates a new Stopping in progress Event
func StoppedEvent(ID string) Event {
return NewEvent(ID, Done, "Stopped")
}
// KillingEvent creates a new Killing in progress Event
func KillingEvent(ID string) Event {
return NewEvent(ID, Working, "Killing")
}
// KilledEvent creates a new Killed in progress Event
func KilledEvent(ID string) Event {
return NewEvent(ID, Done, "Killed")
}
// RemovingEvent creates a new Removing in progress Event
func RemovingEvent(ID string) Event {
return NewEvent(ID, Working, "Removing")
}
// RemovedEvent creates a new removed (done) Event
func RemovedEvent(ID string) Event {
return NewEvent(ID, Done, "Removed")
}
// NewEvent new event
func NewEvent(ID string, status EventStatus, statusText string) Event {
return Event{
ID: ID,
Status: status,
StatusText: statusText,
}
}
func (e *Event) stop() {
e.endTime = time.Now()
e.spinner.Stop()
}

37
pkg/progress/noop.go Normal file
View file

@ -0,0 +1,37 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package progress
import (
"context"
)
type noopWriter struct {
}
func (p *noopWriter) Start(ctx context.Context) error {
return nil
}
func (p *noopWriter) Event(e Event) {
}
func (p *noopWriter) TailMsgf(_ string, _ ...interface{}) {
}
func (p *noopWriter) Stop() {
}

49
pkg/progress/plain.go Normal file
View file

@ -0,0 +1,49 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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.Fprintln(p.out, e.ID, e.Text, e.StatusText)
}
func (p *plainWriter) TailMsgf(m string, args ...interface{}) {
fmt.Fprintln(p.out, append([]interface{}{m}, args...)...)
}
func (p *plainWriter) Stop() {
p.done <- true
}

66
pkg/progress/spinner.go Normal file
View file

@ -0,0 +1,66 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package progress
import (
"runtime"
"time"
)
type spinner struct {
time time.Time
index int
chars []string
stop bool
done string
}
func newSpinner() *spinner {
chars := []string{
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
}
done := "⠿"
if runtime.GOOS == "windows" {
chars = []string{"-"}
done = "-"
}
return &spinner{
index: 0,
time: time.Now(),
chars: chars,
done: 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
}

235
pkg/progress/tty.go Normal file
View file

@ -0,0 +1,235 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package progress
import (
"context"
"fmt"
"io"
"runtime"
"strings"
"sync"
"time"
"github.com/docker/compose-cli/pkg/utils"
"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
tailEvents []string
}
func (w *ttyWriter) Start(ctx context.Context) error {
ticker := time.NewTicker(100 * time.Millisecond)
for {
select {
case <-ctx.Done():
w.print()
w.printTailEvents()
return ctx.Err()
case <-w.done:
w.print()
w.printTailEvents()
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 !utils.StringContains(w.eventIDs, e.ID) {
w.eventIDs = append(w.eventIDs, e.ID)
}
if _, ok := w.events[e.ID]; ok {
last := w.events[e.ID]
switch e.Status {
case Done, Error:
if last.Status != e.Status {
last.stop()
}
}
last.Status = e.Status
last.Text = e.Text
last.StatusText = e.StatusText
last.ParentID = e.ParentID
w.events[e.ID] = last
} else {
e.startTime = time.Now()
e.spinner = newSpinner()
if e.Status == Done || e.Status == Error {
e.stop()
}
w.events[e.ID] = e
}
}
func (w *ttyWriter) TailMsgf(msg string, args ...interface{}) {
w.mtx.Lock()
defer w.mtx.Unlock()
w.tailEvents = append(w.tailEvents, fmt.Sprintf(msg, args...))
}
func (w *ttyWriter) printTailEvents() {
w.mtx.Lock()
defer w.mtx.Unlock()
for _, msg := range w.tailEvents {
fmt.Fprintln(w.out, msg)
}
}
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 {
event := w.events[v]
l := len(fmt.Sprintf("%s %s", event.ID, event.Text))
if statusPadding < l {
statusPadding = l
}
if event.ParentID != "" {
statusPadding -= 2
}
}
numLines := 0
for _, v := range w.eventIDs {
event := w.events[v]
if event.ParentID != "" {
continue
}
line := lineText(event, "", terminalWidth, statusPadding, runtime.GOOS != "windows")
// nolint: errcheck
fmt.Fprint(w.out, line)
numLines++
for _, v := range w.eventIDs {
ev := w.events[v]
if ev.ParentID == event.ID {
line := lineText(ev, " ", terminalWidth, statusPadding, runtime.GOOS != "windows")
// nolint: errcheck
fmt.Fprint(w.out, line)
numLines++
}
}
}
w.numLines = numLines
}
func lineText(event Event, pad string, terminalWidth, statusPadding int, color bool) string {
endTime := time.Now()
if event.Status != Working {
endTime = event.startTime
if (event.endTime != time.Time{}) {
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
}
// calculate the max length for the status text, on errors it
// is 2-3 lines long and breaks the line formating
maxStatusLen := terminalWidth - textLen - statusPadding - 15
status := event.StatusText
// in some cases (debugging under VS Code), terminalWidth is set to zero by goterm.Width() ; ensuring we don't tweak strings with negative char index
if maxStatusLen > 0 && len(status) > maxStatusLen {
status = status[:maxStatusLen] + "..."
}
text := fmt.Sprintf("%s %s %s %s%s %s",
pad,
event.spinner.String(),
event.ID,
event.Text,
strings.Repeat(" ", padding),
status,
)
timer := fmt.Sprintf("%.1fs\n", elapsed)
o := align(text, timer, terminalWidth)
if color {
color := aec.WhiteF
if event.Status == Done {
color = aec.BlueF
}
if event.Status == Error {
color = aec.RedF
}
return aec.Apply(o, color)
}
return o
}
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)
}

105
pkg/progress/tty_test.go Normal file
View file

@ -0,0 +1,105 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package progress
import (
"fmt"
"sync"
"testing"
"time"
"gotest.tools/v3/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, true)
assert.Equal(t, out, "\x1b[37m . id Text Status 0.0s\n\x1b[0m")
out = lineText(ev, "", 50, lineWidth, false)
assert.Equal(t, out, " . id Text Status 0.0s\n")
ev.Status = Done
out = lineText(ev, "", 50, lineWidth, true)
assert.Equal(t, out, "\x1b[34m . id Text Status 0.0s\n\x1b[0m")
ev.Status = Error
out = lineText(ev, "", 50, lineWidth, true)
assert.Equal(t, out, "\x1b[31m . id Text Status 0.0s\n\x1b[0m")
}
func TestLineTextSingleEvent(t *testing.T) {
now := time.Now()
ev := Event{
ID: "id",
Text: "Text",
Status: Done,
StatusText: "Status",
startTime: now,
spinner: &spinner{
chars: []string{"."},
},
}
lineWidth := len(fmt.Sprintf("%s %s", ev.ID, ev.Text))
out := lineText(ev, "", 50, lineWidth, true)
assert.Equal(t, out, "\x1b[34m . id Text Status 0.0s\n\x1b[0m")
}
func TestErrorEvent(t *testing.T) {
w := &ttyWriter{
events: map[string]Event{},
mtx: &sync.RWMutex{},
}
e := Event{
ID: "id",
Text: "Text",
Status: Working,
StatusText: "Working",
startTime: time.Now(),
spinner: &spinner{
chars: []string{"."},
},
}
// Fire "Working" event and check end time isn't touched
w.Event(e)
event, ok := w.events[e.ID]
assert.Assert(t, ok)
assert.Assert(t, event.endTime.Equal(time.Time{}))
// Fire "Error" event and check end time is set
e.Status = Error
w.Event(e)
event, ok = w.events[e.ID]
assert.Assert(t, ok)
assert.Assert(t, event.endTime.After(time.Now().Add(-10*time.Second)))
}

116
pkg/progress/writer.go Normal file
View file

@ -0,0 +1,116 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package progress
import (
"context"
"os"
"sync"
"github.com/containerd/console"
"github.com/moby/term"
"golang.org/x/sync/errgroup"
)
// Writer can write multiple progress events
type Writer interface {
Start(context.Context) error
Stop()
Event(Event)
TailMsgf(string, ...interface{})
}
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, ok := ctx.Value(writerKey{}).(Writer)
if !ok {
return &noopWriter{}
}
return s
}
type progressFunc func(context.Context) error
type progressFuncWithStatus func(context.Context) (string, error)
// Run will run a writer and the progress function in parallel
func Run(ctx context.Context, pf progressFunc) error {
_, err := RunWithStatus(ctx, func(ctx context.Context) (string, error) {
return "", pf(ctx)
})
return err
}
// RunWithStatus will run a writer and the progress function in parallel and return a status
func RunWithStatus(ctx context.Context, pf progressFuncWithStatus) (string, error) {
eg, _ := errgroup.WithContext(ctx)
w, err := NewWriter(os.Stderr)
var result string
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()
s, err := pf(ctx)
if err == nil {
result = s
}
return err
})
err = eg.Wait()
return result, err
}
// 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
}

View file

@ -0,0 +1,31 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package progress
import (
"context"
"testing"
"gotest.tools/v3/assert"
)
func TestNoopWriter(t *testing.T) {
todo := context.TODO()
writer := ContextWriter(todo)
assert.Equal(t, writer, &noopWriter{})
}