From d2f95b09b97f01b8be797d05068bc1e5d016e8e1 Mon Sep 17 00:00:00 2001 From: "Jonathan A. Sternberg" Date: Mon, 21 Aug 2023 13:49:32 -0500 Subject: [PATCH] progressui: adds a json output that shows raw events for the solver status This adds an additional display output for the progress indicator to support a json output. It refators the progressui package a bit to add a new method that takes in a `SolveStatusDisplay`. This `SolveStatusDisplay` can be created by the user using `NewDisplay` with the various modes as input parameters. The json output will print the raw events as JSON blobs. It will not throttle the messages or limit the display. It is meant as a pure raw marshaling of the underlying event stream. Signed-off-by: Jonathan A. Sternberg --- client/graph.go | 63 +++--- cmd/buildctl/build.go | 2 +- docs/reference/buildctl.md | 2 +- examples/build-using-dockerfile/main.go | 10 +- util/progress/progressui/display.go | 280 +++++++++++++++++++----- util/progress/progresswriter/printer.go | 19 +- 6 files changed, 267 insertions(+), 109 deletions(-) diff --git a/client/graph.go b/client/graph.go index aaa96f293f1af..c24e73d45e08a 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 e776a71e4ae8e..2db8bce99df1e 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, raw:json). Use plain to show container output", Value: "auto", }, cli.StringFlag{ diff --git a/docs/reference/buildctl.md b/docs/reference/buildctl.md index 61e8b9b5b504f..d1077901f0600 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, raw:json). 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 713ceb6f92763..a6d728ee53c28 100644 --- a/examples/build-using-dockerfile/main.go +++ b/examples/build-using-dockerfile/main.go @@ -102,12 +102,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 + var d progressui.SolveStatusDisplay + if c, err := console.ConsoleFromFile(os.Stderr); err == nil { + d = progressui.NewConsoleDisplay(c) + } else { + d = progressui.NewPlainDisplay(os.Stdout) } // 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 4ceb4f5264e66..fb987f85c7a36 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,6 +17,7 @@ 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" @@ -27,6 +29,14 @@ type displaySolveStatusOpts struct { consoleDesc string } +func newDisplaySolveStatusOpts(opts ...DisplaySolveStatusOpt) *displaySolveStatusOpts { + dsso := &displaySolveStatusOpts{} + for _, opt := range opts { + opt(dsso) + } + return dsso +} + type DisplaySolveStatusOpt func(b *displaySolveStatusOpts) func WithPhase(phase string) DisplaySolveStatusOpt { @@ -42,23 +52,32 @@ func WithDesc(text string, console string) DisplaySolveStatusOpt { } } -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 SolveStatusDisplay struct { + disp solveStatusDisplay +} - t := newTrace(w, modeConsole) +type solveStatusDisplay interface { + // init initializes the display and opens any resources + // that are required. + init(displayTimeout time.Duration) + + // 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 SolveStatusDisplay) UpdateFrom(ctx context.Context, ch chan *client.SolveStatus) ([]client.VertexWarning, error) { tickerTimeout := 150 * time.Millisecond displayTimeout := 100 * time.Millisecond @@ -69,53 +88,208 @@ 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() - }() + d.disp.init(displayTimeout) + defer d.disp.done() - displayLimiter := rate.NewLimiter(rate.Every(displayTimeout), 1) + 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) + } + } +} + +func (d SolveStatusDisplay) Warnings() []client.VertexWarning { + return nil +} + +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 = "raw:json" +) + +// NewDisplay constructs a SolveStatusDisplay 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 ...DisplaySolveStatusOpt) (SolveStatusDisplay, 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 SolveStatusDisplay{}, errors.Wrap(err, "failed to get console") + } + fallthrough + case PlainMode: + return NewPlainDisplay(out, opts...), nil + case RawJSONMode: + return NewRawJSONDisplay(out, opts...), nil + default: + return SolveStatusDisplay{}, errors.Errorf("invalid progress mode %s", mode) + } +} + +type consoleDisplay struct { + t *trace + disp *display + width, height int + displayLimiter *rate.Limiter +} + +// NewConsoleDisplay creates a new SolveStatusDisplay that prints a TTY +// friendly output. +func NewConsoleDisplay(c console.Console, opts ...DisplaySolveStatusOpt) SolveStatusDisplay { + dsso := newDisplaySolveStatusOpts(opts...) + if dsso.phase == "" { + dsso.phase = "Building" + } + return SolveStatusDisplay{ + disp: &consoleDisplay{ + t: newTrace(c, true), + disp: &display{c: c, phase: dsso.phase, desc: dsso.consoleDesc}, + }, + } +} + +func (d *consoleDisplay) init(displayTimeout time.Duration) { + d.displayLimiter = rate.NewLimiter(rate.Every(displayTimeout), 1) +} + +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 SolveStatusDisplay that outputs the status +// in a human-readable plain-text format. +func NewPlainDisplay(w io.Writer, opts ...DisplaySolveStatusOpt) SolveStatusDisplay { + dsso := newDisplaySolveStatusOpts(opts...) + return SolveStatusDisplay{ + disp: &plainDisplay{ + t: newTrace(w, false), + printer: &textMux{ + w: w, + desc: dsso.textDesc, + }, + }, + } +} + +func (d *plainDisplay) init(displayTimeout time.Duration) { + d.displayLimiter = rate.NewLimiter(rate.Every(displayTimeout), 1) +} + +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 SolveStatusDisplay that outputs an unbuffered +// output of status update events. +func NewRawJSONDisplay(w io.Writer, opts ...DisplaySolveStatusOpt) SolveStatusDisplay { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return SolveStatusDisplay{ + disp: &rawJSONDisplay{ + enc: enc, + w: w, + }, + } +} + +func (d *rawJSONDisplay) init(displayTimeout time.Duration) { + // 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() { + // Flush if the underling writer supports that method. + if flusher, ok := d.w.(interface { + Flush() error + }); ok { + _ = flusher.Flush() + } } const termHeight = 6 @@ -386,14 +560,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 diff --git a/util/progress/progresswriter/printer.go b/util/progress/progresswriter/printer.go index c400e4121531e..ed25cda8f7c11 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