diff --git a/client/graph.go b/client/graph.go index aaa96f293f1a..c24e73d45e08 100644 --- a/client/graph.go +++ b/client/graph.go @@ -8,49 +8,50 @@ import ( ) type Vertex struct { - Digest digest.Digest - Inputs []digest.Digest - Name string - Started *time.Time - Completed *time.Time - Cached bool - Error string - ProgressGroup *pb.ProgressGroup + Digest digest.Digest `json:"digest,omitempty"` + Inputs []digest.Digest `json:"inputs,omitempty"` + Name string `json:"name,omitempty"` + Started *time.Time `json:"started,omitempty"` + Completed *time.Time `json:"completed,omitempty"` + Cached bool `json:"cached,omitempty"` + Error string `json:"error,omitempty"` + ProgressGroup *pb.ProgressGroup `json:"progressGroup,omitempty"` } type VertexStatus struct { - ID string - Vertex digest.Digest - Name string - Total int64 - Current int64 - Timestamp time.Time - Started *time.Time - Completed *time.Time + ID string `json:"id"` + Vertex digest.Digest `json:"vertex,omitempty"` + Name string `json:"name,omitempty"` + Total int64 `json:"total,omitempty"` + Current int64 `json:"current"` + Timestamp time.Time `json:"timestamp,omitempty"` + Started *time.Time `json:"started,omitempty"` + Completed *time.Time `json:"completed,omitempty"` } type VertexLog struct { - Vertex digest.Digest - Stream int - Data []byte - Timestamp time.Time + Vertex digest.Digest `json:"vertex,omitempty"` + Stream int `json:"stream,omitempty"` + Data []byte `json:"data"` + Timestamp time.Time `json:"timestamp"` } type VertexWarning struct { - Vertex digest.Digest - Level int - Short []byte - Detail [][]byte - URL string - SourceInfo *pb.SourceInfo - Range []*pb.Range + Vertex digest.Digest `json:"vertex,omitempty"` + Level int `json:"level,omitempty"` + Short []byte `json:"short,omitempty"` + Detail [][]byte `json:"detail,omitempty"` + URL string `json:"url,omitempty"` + + SourceInfo *pb.SourceInfo `json:"sourceInfo,omitempty"` + Range []*pb.Range `json:"range,omitempty"` } type SolveStatus struct { - Vertexes []*Vertex - Statuses []*VertexStatus - Logs []*VertexLog - Warnings []*VertexWarning + Vertexes []*Vertex `json:"vertexes,omitempty"` + Statuses []*VertexStatus `json:"statuses,omitempty"` + Logs []*VertexLog `json:"logs,omitempty"` + Warnings []*VertexWarning `json:"warnings,omitempty"` } type SolveResponse struct { diff --git a/cmd/buildctl/build.go b/cmd/buildctl/build.go index e776a71e4ae8..911c142f10a8 100644 --- a/cmd/buildctl/build.go +++ b/cmd/buildctl/build.go @@ -46,7 +46,7 @@ var buildCommand = cli.Command{ }, cli.StringFlag{ Name: "progress", - Usage: "Set type of progress (auto, plain, tty). Use plain to show container output", + Usage: "Set type of progress (auto, plain, tty, rawjson). Use plain to show container output", Value: "auto", }, cli.StringFlag{ diff --git a/docs/reference/buildctl.md b/docs/reference/buildctl.md index 61e8b9b5b504..a241c9647f3b 100644 --- a/docs/reference/buildctl.md +++ b/docs/reference/buildctl.md @@ -63,7 +63,7 @@ USAGE: OPTIONS: --output value, -o value Define exports for build result, e.g. --output type=image,name=docker.io/username/image,push=true - --progress value Set type of progress (auto, plain, tty). Use plain to show container output (default: "auto") + --progress value Set type of progress (auto, plain, tty, rawjson). Use plain to show container output (default: "auto") --trace value Path to trace file. Defaults to no tracing. --local value Allow build access to the local directory --oci-layout value Allow build access to the local OCI layout diff --git a/examples/build-using-dockerfile/main.go b/examples/build-using-dockerfile/main.go index 713ceb6f9276..0368ee8e5605 100644 --- a/examples/build-using-dockerfile/main.go +++ b/examples/build-using-dockerfile/main.go @@ -9,7 +9,6 @@ import ( "path/filepath" "strings" - "github.com/containerd/console" "github.com/moby/buildkit/client" dockerfile "github.com/moby/buildkit/frontend/dockerfile/builder" "github.com/moby/buildkit/util/appcontext" @@ -102,12 +101,14 @@ func action(clicontext *cli.Context) error { return err }) eg.Go(func() error { - var c console.Console - if cn, err := console.ConsoleFromFile(os.Stderr); err == nil { - c = cn + d, err := progressui.NewDisplay(os.Stderr, progressui.TtyMode) + if err != nil { + // If an error occurs while attempting to create the tty display, + // fallback to using plain mode on stdout (in contrast to stderr). + d, _ = progressui.NewDisplay(os.Stdout, progressui.PlainMode) } // not using shared context to not disrupt display but let is finish reporting errors - _, err = progressui.DisplaySolveStatus(context.TODO(), c, os.Stdout, ch) + _, err = d.UpdateFrom(context.TODO(), ch) return err }) eg.Go(func() error { diff --git a/util/progress/progressui/display.go b/util/progress/progressui/display.go index 4ceb4f5264e6..78b1ca0ce372 100644 --- a/util/progress/progressui/display.go +++ b/util/progress/progressui/display.go @@ -4,6 +4,7 @@ import ( "bytes" "container/ring" "context" + "encoding/json" "fmt" "io" "os" @@ -16,49 +17,67 @@ import ( "github.com/moby/buildkit/client" "github.com/morikuni/aec" digest "github.com/opencontainers/go-digest" + "github.com/pkg/errors" "github.com/tonistiigi/units" "github.com/tonistiigi/vt100" "golang.org/x/time/rate" ) -type displaySolveStatusOpts struct { +type displayOpts struct { phase string textDesc string consoleDesc string } -type DisplaySolveStatusOpt func(b *displaySolveStatusOpts) +func newDisplayOpts(opts ...DisplayOpt) *displayOpts { + dsso := &displayOpts{} + for _, opt := range opts { + opt(dsso) + } + return dsso +} + +type DisplayOpt func(b *displayOpts) -func WithPhase(phase string) DisplaySolveStatusOpt { - return func(b *displaySolveStatusOpts) { +func WithPhase(phase string) DisplayOpt { + return func(b *displayOpts) { b.phase = phase } } -func WithDesc(text string, console string) DisplaySolveStatusOpt { - return func(b *displaySolveStatusOpts) { +func WithDesc(text string, console string) DisplayOpt { + return func(b *displayOpts) { b.textDesc = text b.consoleDesc = console } } -func DisplaySolveStatus(ctx context.Context, c console.Console, w io.Writer, ch chan *client.SolveStatus, opts ...DisplaySolveStatusOpt) ([]client.VertexWarning, error) { - modeConsole := c != nil - - dsso := &displaySolveStatusOpts{} - for _, opt := range opts { - opt(dsso) - } - - disp := &display{c: c, phase: dsso.phase, desc: dsso.consoleDesc} - printer := &textMux{w: w, desc: dsso.textDesc} - - if disp.phase == "" { - disp.phase = "Building" - } +type Display struct { + disp display +} - t := newTrace(w, modeConsole) +type display interface { + // init initializes the display and opens any resources + // that are required. + init(displayLimiter *rate.Limiter) + + // update sends the signal to update the display. + // Some displays will have buffered output and will not + // display changes for every status update. + update(ss *client.SolveStatus) + + // refresh updates the display with the latest state. + // This method only does something with displays that + // have buffered output. + refresh() + + // done is invoked when the display will be closed. + // This method should flush any buffers and close any open + // resources that were opened by init. + done() +} +func (d Display) UpdateFrom(ctx context.Context, ch chan *client.SolveStatus) ([]client.VertexWarning, error) { tickerTimeout := 150 * time.Millisecond displayTimeout := 100 * time.Millisecond @@ -69,53 +88,200 @@ func DisplaySolveStatus(ctx context.Context, c console.Console, w io.Writer, ch } } - var done bool - ticker := time.NewTicker(tickerTimeout) - // implemented as closure because "ticker" can change - defer func() { - ticker.Stop() - }() - displayLimiter := rate.NewLimiter(rate.Every(displayTimeout), 1) + d.disp.init(displayLimiter) + defer d.disp.done() + + ticker := time.NewTicker(tickerTimeout) + defer ticker.Stop() - var height int - width, _ := disp.getSize() + var warnings []client.VertexWarning for { select { case <-ctx.Done(): return nil, ctx.Err() case <-ticker.C: + d.disp.refresh() case ss, ok := <-ch: - if ok { - t.update(ss, width) - } else { - done = true + if !ok { + return warnings, nil } - } - if modeConsole { - width, height = disp.getSize() - if done { - disp.print(t.displayInfo(), width, height, true) - t.printErrorLogs(c) - return t.warnings(), nil - } else if displayLimiter.Allow() { - ticker.Stop() - ticker = time.NewTicker(tickerTimeout) - disp.print(t.displayInfo(), width, height, false) - } - } else { - if done || displayLimiter.Allow() { - printer.print(t) - if done { - t.printErrorLogs(w) - return t.warnings(), nil - } - ticker.Stop() - ticker = time.NewTicker(tickerTimeout) + d.disp.update(ss) + for _, w := range ss.Warnings { + warnings = append(warnings, *w) } + ticker.Reset(tickerTimeout) + } + } +} + +type DisplayMode string + +const ( + // DefaultMode is the default value for the DisplayMode. + // This is effectively the same as AutoMode. + DefaultMode DisplayMode = "" + // AutoMode will choose TtyMode or PlainMode depending on if the output is + // a tty. + AutoMode DisplayMode = "auto" + // TtyMode enforces the output is a tty and will otherwise cause an error if it isn't. + TtyMode DisplayMode = "tty" + // PlainMode is the human-readable plain text output. This mode is not meant to be read + // by machines. + PlainMode DisplayMode = "plain" + // RawJSONMode is the raw JSON text output. It will marshal the various solve status events + // to JSON to be read by an external program. + RawJSONMode DisplayMode = "rawjson" +) + +// NewDisplay constructs a Display that outputs to the given console.File with the given DisplayMode. +// +// This method will return an error when the DisplayMode is invalid or if TtyMode is used but the console.File +// does not refer to a tty. AutoMode will choose TtyMode or PlainMode depending on if the output is a tty or not. +func NewDisplay(out console.File, mode DisplayMode, opts ...DisplayOpt) (Display, error) { + switch mode { + case AutoMode, TtyMode, DefaultMode: + if c, err := console.ConsoleFromFile(out); err == nil { + return newConsoleDisplay(c, opts...), nil + } else if mode == "tty" { + return Display{}, errors.Wrap(err, "failed to get console") + } + fallthrough + case PlainMode: + return newPlainDisplay(out, opts...), nil + case RawJSONMode: + return newRawJSONDisplay(out, opts...), nil + default: + return Display{}, errors.Errorf("invalid progress mode %s", mode) + } +} + +type consoleDisplay struct { + t *trace + disp *ttyDisplay + width, height int + displayLimiter *rate.Limiter +} + +// newConsoleDisplay creates a new Display that prints a TTY +// friendly output. +func newConsoleDisplay(c console.Console, opts ...DisplayOpt) Display { + dsso := newDisplayOpts(opts...) + if dsso.phase == "" { + dsso.phase = "Building" + } + return Display{ + disp: &consoleDisplay{ + t: newTrace(c, true), + disp: &ttyDisplay{c: c, phase: dsso.phase, desc: dsso.consoleDesc}, + }, + } +} + +func (d *consoleDisplay) init(displayLimiter *rate.Limiter) { + d.displayLimiter = displayLimiter +} + +func (d *consoleDisplay) update(ss *client.SolveStatus) { + d.width, d.height = d.disp.getSize() + d.t.update(ss, d.width) + if !d.displayLimiter.Allow() { + // Exit early as we are not allowed to update the display. + return + } + d.refresh() +} + +func (d *consoleDisplay) refresh() { + d.disp.print(d.t.displayInfo(), d.width, d.height, false) +} + +func (d *consoleDisplay) done() { + d.width, d.height = d.disp.getSize() + d.disp.print(d.t.displayInfo(), d.width, d.height, true) + d.t.printErrorLogs(d.t.w) +} + +type plainDisplay struct { + t *trace + printer *textMux + displayLimiter *rate.Limiter +} + +// newPlainDisplay creates a new Display that outputs the status +// in a human-readable plain-text format. +func newPlainDisplay(w io.Writer, opts ...DisplayOpt) Display { + dsso := newDisplayOpts(opts...) + return Display{ + disp: &plainDisplay{ + t: newTrace(w, false), + printer: &textMux{ + w: w, + desc: dsso.textDesc, + }, + }, + } +} + +func (d *plainDisplay) init(displayLimiter *rate.Limiter) { + d.displayLimiter = displayLimiter +} + +func (d *plainDisplay) update(ss *client.SolveStatus) { + if ss != nil { + d.t.update(ss, 80) + if !d.displayLimiter.Allow() { + // Exit early as we are not allowed to update the display. + return } } + d.refresh() +} + +func (d *plainDisplay) refresh() { + d.printer.print(d.t) +} + +func (d *plainDisplay) done() { + // Force the display to refresh. + d.refresh() + // Print error logs. + d.t.printErrorLogs(d.t.w) +} + +type rawJSONDisplay struct { + enc *json.Encoder + w io.Writer +} + +// newRawJSONDisplay creates a new Display that outputs an unbuffered +// output of status update events. +func newRawJSONDisplay(w io.Writer, opts ...DisplayOpt) Display { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return Display{ + disp: &rawJSONDisplay{ + enc: enc, + w: w, + }, + } +} + +func (d *rawJSONDisplay) init(displayLimiter *rate.Limiter) { + // Initialization parameters are ignored for this display. +} + +func (d *rawJSONDisplay) update(ss *client.SolveStatus) { + _ = d.enc.Encode(ss) +} + +func (d *rawJSONDisplay) refresh() { + // Unbuffered display doesn't have anything to refresh. +} + +func (d *rawJSONDisplay) done() { + // No actions needed. } const termHeight = 6 @@ -386,14 +552,6 @@ func newTrace(w io.Writer, modeConsole bool) *trace { } } -func (t *trace) warnings() []client.VertexWarning { - var out []client.VertexWarning - for _, v := range t.vertexes { - out = append(out, v.warnings...) - } - return out -} - func (t *trace) triggerVertexEvent(v *client.Vertex) { if v.Started == nil { return @@ -734,7 +892,7 @@ func addTime(tm *time.Time, d time.Duration) *time.Time { return &t } -type display struct { +type ttyDisplay struct { c console.Console phase string desc string @@ -742,7 +900,7 @@ type display struct { repeated bool } -func (disp *display) getSize() (int, int) { +func (disp *ttyDisplay) getSize() (int, int) { width := 80 height := 10 if disp.c != nil { @@ -789,7 +947,7 @@ func setupTerminals(jobs []*job, height int, all bool) []*job { return jobs } -func (disp *display) print(d displayInfo, width, height int, all bool) { +func (disp *ttyDisplay) print(d displayInfo, width, height int, all bool) { // this output is inspired by Buck d.jobs = setupTerminals(d.jobs, height, all) b := aec.EmptyBuilder diff --git a/util/progress/progresswriter/printer.go b/util/progress/progresswriter/printer.go index c400e4121531..ed25cda8f7c1 100644 --- a/util/progress/progresswriter/printer.go +++ b/util/progress/progresswriter/printer.go @@ -7,7 +7,6 @@ import ( "github.com/containerd/console" "github.com/moby/buildkit/client" "github.com/moby/buildkit/util/progress/progressui" - "github.com/pkg/errors" ) type printer struct { @@ -70,24 +69,14 @@ func NewPrinter(ctx context.Context, out console.File, mode string) (Writer, err mode = v } - var c console.Console - switch mode { - case "auto", "tty", "": - if cons, err := console.ConsoleFromFile(out); err == nil { - c = cons - } else { - if mode == "tty" { - return nil, errors.Wrap(err, "failed to get console") - } - } - case "plain": - default: - return nil, errors.Errorf("invalid progress mode %s", mode) + d, err := progressui.NewDisplay(out, progressui.DisplayMode(mode)) + if err != nil { + return nil, err } go func() { // not using shared context to not disrupt display but let is finish reporting errors - _, pw.err = progressui.DisplaySolveStatus(ctx, c, out, statusCh) + _, pw.err = d.UpdateFrom(ctx, statusCh) close(doneCh) }() return pw, nil