register TTYWritter as an Event Processor

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2025-10-27 09:45:35 +01:00 committed by Guillaume Lours
parent ae25d27e5a
commit fd4f2f99cf
49 changed files with 409 additions and 710 deletions

View file

@ -16,6 +16,8 @@
package progress
import "context"
// EventStatus indicates the status of an action
type EventStatus int
@ -159,3 +161,13 @@ func NewEvent(id string, status EventStatus, statusText string) Event {
StatusText: statusText,
}
}
// EventProcessor is notified about Compose operations and tasks
type EventProcessor interface {
// Start is triggered as a Compose operation is starting with context
Start(ctx context.Context, operation string)
// On notify about (sub)task and progress processing operation
On(events ...Event)
// Done is triggered as a Compose operation completed
Done(operation string, success bool)
}

View file

@ -23,9 +23,14 @@ import (
"io"
)
func NewJSONWriter(out io.Writer) EventProcessor {
return &jsonWriter{
out: out,
}
}
type jsonWriter struct {
out io.Writer
done chan bool
dryRun bool
}
@ -41,13 +46,7 @@ type jsonMessage struct {
Percent int `json:"percent,omitempty"`
}
func (p *jsonWriter) Start(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-p.done:
return nil
}
func (p *jsonWriter) Start(ctx context.Context, operation string) {
}
func (p *jsonWriter) Event(e Event) {
@ -68,29 +67,11 @@ func (p *jsonWriter) Event(e Event) {
}
}
func (p *jsonWriter) Events(events []Event) {
func (p *jsonWriter) On(events ...Event) {
for _, e := range events {
p.Event(e)
}
}
func (p *jsonWriter) TailMsgf(msg string, args ...interface{}) {
message := &jsonMessage{
DryRun: p.dryRun,
Tail: true,
ID: "",
Text: fmt.Sprintf(msg, args...),
Status: "",
}
marshal, err := json.Marshal(message)
if err == nil {
_, _ = fmt.Fprintln(p.out, string(marshal))
}
}
func (p *jsonWriter) Stop() {
p.done <- true
}
func (p *jsonWriter) HasMore(bool) {
func (p *jsonWriter) Done(_ string, _ bool) {
}

View file

@ -18,7 +18,6 @@ package progress
import (
"bytes"
"context"
"encoding/json"
"testing"
@ -29,7 +28,6 @@ func TestJsonWriter_Event(t *testing.T) {
var out bytes.Buffer
w := &jsonWriter{
out: &out,
done: make(chan bool),
dryRun: true,
}
@ -60,30 +58,3 @@ func TestJsonWriter_Event(t *testing.T) {
}
assert.DeepEqual(t, expected, actual)
}
func TestJsonWriter_TailMsgf(t *testing.T) {
var out bytes.Buffer
w := &jsonWriter{
out: &out,
done: make(chan bool),
dryRun: false,
}
go func() {
_ = w.Start(context.Background())
}()
w.TailMsgf("hello %s", "world")
w.Stop()
var actual jsonMessage
err := json.Unmarshal(out.Bytes(), &actual)
assert.NilError(t, err)
expected := jsonMessage{
Tail: true,
Text: "hello world",
}
assert.DeepEqual(t, expected, actual)
}

View file

@ -1,76 +0,0 @@
/*
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"
"github.com/docker/cli/cli/streams"
"github.com/docker/compose/v2/pkg/api"
)
// NewMixedWriter creates a Writer which allows to mix output from progress.Writer with a api.LogConsumer
func NewMixedWriter(out *streams.Out, consumer api.LogConsumer, dryRun bool) Writer {
isTerminal := out.IsTerminal()
if Mode != ModeAuto || !isTerminal {
return &plainWriter{
out: out,
done: make(chan bool),
dryRun: dryRun,
}
}
return &mixedWriter{
out: consumer,
done: make(chan bool),
dryRun: dryRun,
}
}
type mixedWriter struct {
done chan bool
dryRun bool
out api.LogConsumer
}
func (p *mixedWriter) Start(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-p.done:
return nil
}
}
func (p *mixedWriter) Event(e Event) {
p.out.Status("", fmt.Sprintf("%s %s %s", e.ID, e.Text, SuccessColor(e.StatusText)))
}
func (p *mixedWriter) Events(events []Event) {
for _, e := range events {
p.Event(e)
}
}
func (p *mixedWriter) TailMsgf(msg string, args ...interface{}) {
msg = fmt.Sprintf(msg, args...)
p.out.Status("", WarningColor(msg))
}
func (p *mixedWriter) Stop() {
p.done <- true
}

View file

@ -1,39 +0,0 @@
/*
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(Event) {
}
func (p *noopWriter) Events([]Event) {
}
func (p *noopWriter) TailMsgf(_ string, _ ...interface{}) {
}
func (p *noopWriter) Stop() {
}

View file

@ -24,19 +24,18 @@ import (
"github.com/docker/compose/v2/pkg/api"
)
func NewPlainWriter(out io.Writer) EventProcessor {
return &plainWriter{
out: out,
}
}
type plainWriter struct {
out io.Writer
done chan bool
dryRun bool
}
func (p *plainWriter) Start(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-p.done:
return nil
}
func (p *plainWriter) Start(ctx context.Context, operation string) {
}
func (p *plainWriter) Event(e Event) {
@ -47,20 +46,11 @@ func (p *plainWriter) Event(e Event) {
_, _ = fmt.Fprintln(p.out, prefix, e.ID, e.Text, e.StatusText)
}
func (p *plainWriter) Events(events []Event) {
func (p *plainWriter) On(events ...Event) {
for _, e := range events {
p.Event(e)
}
}
func (p *plainWriter) TailMsgf(msg string, args ...interface{}) {
msg = fmt.Sprintf(msg, args...)
if p.dryRun {
msg = api.DRYRUN_PREFIX + msg
}
_, _ = fmt.Fprintln(p.out, msg)
}
func (p *plainWriter) Stop() {
p.done <- true
func (p *plainWriter) Done(_ string, _ bool) {
}

53
pkg/progress/progress.go Normal file
View file

@ -0,0 +1,53 @@
/*
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"
"github.com/docker/compose/v2/pkg/api"
)
type progressFunc func(context.Context) error
func RunWithLog(ctx context.Context, pf progressFunc, operation string, bus EventProcessor, logConsumer api.LogConsumer) error {
// FIXME(ndeloof) re-implement support for logs during stop sequence
return pf(ctx)
}
func Run(ctx context.Context, pf progressFunc, operation string, bus EventProcessor) error {
bus.Start(ctx, operation)
err := pf(ctx)
bus.Done(operation, err != nil)
return err
}
const (
// ModeAuto detect console capabilities
ModeAuto = "auto"
// ModeTTY use terminal capability for advanced rendering
ModeTTY = "tty"
// ModePlain dump raw events to output
ModePlain = "plain"
// ModeQuiet don't display events
ModeQuiet = "quiet"
// ModeJSON outputs a machine-readable JSON stream
ModeJSON = "json"
)
// Mode define how progress should be rendered, either as ModePlain or ModeTTY
var Mode = ModeAuto

View file

@ -18,20 +18,17 @@ package progress
import "context"
func NewQuiedWriter() EventProcessor {
return &quiet{}
}
type quiet struct{}
func (q quiet) Start(_ context.Context) error {
return nil
func (q *quiet) Start(_ context.Context, _ string) {
}
func (q quiet) Stop() {
func (q *quiet) Done(_ string, _ bool) {
}
func (q quiet) Event(_ Event) {
}
func (q quiet) Events(_ []Event) {
}
func (q quiet) TailMsgf(_ string, _ ...interface{}) {
func (q *quiet) On(_ ...Event) {
}

View file

@ -32,6 +32,18 @@ import (
"github.com/morikuni/aec"
)
// NewTTYWriter creates an EventProcessor that render advanced UI within a terminal.
// On Start, TUI lists task with a progress timer
func NewTTYWriter(out io.Writer) EventProcessor {
return &ttyWriter{
out: out,
tasks: map[string]task{},
ids: []string{},
done: make(chan bool),
mtx: &sync.Mutex{},
}
}
type ttyWriter struct {
out io.Writer
tasks map[string]task
@ -40,10 +52,10 @@ type ttyWriter struct {
numLines int
done chan bool
mtx *sync.Mutex
tailEvents []string
dryRun bool
dryRun bool // FIXME(ndeloof) (re)implement support for dry-run
skipChildEvents bool
progressTitle string
title string
ticker *time.Ticker
}
type task struct {
@ -69,34 +81,40 @@ func (t *task) hasMore() {
t.spinner.Restart()
}
func (w *ttyWriter) Start(ctx context.Context) error {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
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) Start(ctx context.Context, operation string) {
w.ticker = time.NewTicker(100 * time.Millisecond)
w.title = operation
go func() {
for {
select {
case <-ctx.Done():
// interrupted
w.ticker.Stop()
return
case <-w.done:
w.print()
w.mtx.Lock()
w.ticker.Stop()
w.title = ""
w.mtx.Unlock()
return
case <-w.ticker.C:
w.print()
}
}
}
}()
}
func (w *ttyWriter) Stop() {
func (w *ttyWriter) Done(operation string, success bool) {
w.done <- true
}
func (w *ttyWriter) Event(e Event) {
func (w *ttyWriter) On(events ...Event) {
w.mtx.Lock()
defer w.mtx.Unlock()
w.event(e)
for _, e := range events {
w.event(e)
}
}
func (w *ttyWriter) event(e Event) {
@ -149,32 +167,27 @@ func (w *ttyWriter) event(e Event) {
}
w.tasks[e.ID] = t
}
w.printEvent(e)
}
func (w *ttyWriter) Events(events []Event) {
w.mtx.Lock()
defer w.mtx.Unlock()
for _, e := range events {
w.event(e)
func (w *ttyWriter) printEvent(e Event) {
if w.title != "" {
// event will be displayed by progress UI on ticker's ticks
return
}
}
func (w *ttyWriter) TailMsgf(msg string, args ...interface{}) {
w.mtx.Lock()
defer w.mtx.Unlock()
msgWithPrefix := msg
if w.dryRun {
msgWithPrefix = strings.TrimSpace(api.DRYRUN_PREFIX + msg)
}
w.tailEvents = append(w.tailEvents, fmt.Sprintf(msgWithPrefix, args...))
}
func (w *ttyWriter) printTailEvents() {
w.mtx.Lock()
defer w.mtx.Unlock()
for _, msg := range w.tailEvents {
_, _ = fmt.Fprintln(w.out, msg)
var color colorFunc
switch e.Status {
case Working:
color = SuccessColor
case Done:
color = SuccessColor
case Warning:
color = WarningColor
case Error:
color = ErrorColor
}
_, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, e.Text, color(e.StatusText))
}
func (w *ttyWriter) print() { //nolint:gocyclo
@ -200,7 +213,7 @@ func (w *ttyWriter) print() { //nolint:gocyclo
_, _ = fmt.Fprint(w.out, aec.Show)
}()
firstLine := fmt.Sprintf("[+] %s %d/%d", w.progressTitle, numDone(w.tasks), len(w.tasks))
firstLine := fmt.Sprintf("[+] %s %d/%d", w.title, numDone(w.tasks), len(w.tasks))
if w.numLines != 0 && numDone(w.tasks) == w.numLines {
firstLine = DoneColor(firstLine)
}

View file

@ -1,149 +0,0 @@
/*
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"
"sync"
"github.com/docker/cli/cli/streams"
"golang.org/x/sync/errgroup"
"github.com/docker/compose/v2/pkg/api"
)
// Writer can write multiple progress events
type Writer interface {
Start(context.Context) error
Stop()
Event(Event)
Events([]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
func RunWithLog(ctx context.Context, pf progressFunc, out *streams.Out, logConsumer api.LogConsumer) error {
w := NewMixedWriter(out, logConsumer, false) // FIXME(ndeloof) re-implement dry-run
eg, _ := errgroup.WithContext(ctx)
eg.Go(func() error {
return w.Start(context.Background())
})
eg.Go(func() error {
defer w.Stop()
ctx = WithContextWriter(ctx, w)
err := pf(ctx)
return err
})
return eg.Wait()
}
func Run(ctx context.Context, pf progressFunc, out *streams.Out, progressTitle string) error {
eg, _ := errgroup.WithContext(ctx)
w, err := NewWriter(ctx, out, progressTitle)
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()
err := pf(ctx)
return err
})
return eg.Wait()
}
const (
// ModeAuto detect console capabilities
ModeAuto = "auto"
// ModeTTY use terminal capability for advanced rendering
ModeTTY = "tty"
// ModePlain dump raw events to output
ModePlain = "plain"
// ModeQuiet don't display events
ModeQuiet = "quiet"
// ModeJSON outputs a machine-readable JSON stream
ModeJSON = "json"
)
// Mode define how progress should be rendered, either as ModePlain or ModeTTY
var Mode = ModeAuto
// NewWriter returns a new multi-progress writer
func NewWriter(ctx context.Context, out *streams.Out, progressTitle string) (Writer, error) {
isTerminal := out.IsTerminal()
switch Mode {
case ModeQuiet:
return quiet{}, nil
case ModeJSON:
return &jsonWriter{
out: out,
done: make(chan bool),
dryRun: false, // FIXME(ndeloof) re-implement dry-run
}, nil
case ModeTTY:
return newTTYWriter(out, false, progressTitle)
case ModeAuto, "":
if isTerminal {
return newTTYWriter(out, false, progressTitle)
}
fallthrough
case ModePlain:
return &plainWriter{
out: out,
done: make(chan bool),
dryRun: false,
}, nil
}
return nil, fmt.Errorf("unknown progress mode: %s", Mode)
}
func newTTYWriter(out io.Writer, dryRun bool, progressTitle string) (Writer, error) {
return &ttyWriter{
out: out,
ids: []string{},
tasks: map[string]task{},
repeated: false,
done: make(chan bool),
mtx: &sync.Mutex{},
dryRun: dryRun,
progressTitle: progressTitle,
}, nil
}

View file

@ -1,31 +0,0 @@
/*
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{})
}