소스 검색

Add test for buildkit history trace propagation

This test ensures that we are able to propagate traces into buildkit's
history API.

Signed-off-by: Brian Goff <cpuguy83@gmail.com>
Brian Goff 1 년 전
부모
커밋
9b7784781d

+ 114 - 0
integration/build/build_traces_test.go

@@ -0,0 +1,114 @@
+package build
+
+import (
+	"context"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/docker/docker/client/buildkit"
+	"github.com/docker/docker/testutil"
+	moby_buildkit_v1 "github.com/moby/buildkit/api/services/control"
+	"github.com/moby/buildkit/client"
+	"github.com/moby/buildkit/client/llb"
+	"github.com/moby/buildkit/util/progress/progressui"
+	"go.opentelemetry.io/otel"
+	"golang.org/x/sync/errgroup"
+	"gotest.tools/v3/assert"
+	"gotest.tools/v3/poll"
+	"gotest.tools/v3/skip"
+)
+
+type testWriter struct {
+	*testing.T
+}
+
+func (t *testWriter) Write(p []byte) (int, error) {
+	t.Log(string(p))
+	return len(p), nil
+}
+
+func TestBuildkitHistoryTracePropagation(t *testing.T) {
+	skip.If(t, testEnv.DaemonInfo.OSType == "windows", "buildkit is not supported on Windows")
+
+	ctx := testutil.StartSpan(baseContext, t)
+
+	opts := buildkit.ClientOpts(testEnv.APIClient())
+	bc, err := client.New(ctx, "", opts...)
+	assert.NilError(t, err)
+	defer bc.Close()
+
+	def, err := llb.Scratch().Marshal(ctx)
+	assert.NilError(t, err)
+
+	eg, ctxGo := errgroup.WithContext(ctx)
+	ch := make(chan *client.SolveStatus)
+
+	ctxHistory, cancel := context.WithCancel(ctx)
+	defer cancel()
+
+	sub, err := bc.ControlClient().ListenBuildHistory(ctxHistory, &moby_buildkit_v1.BuildHistoryRequest{ActiveOnly: true})
+	assert.NilError(t, err)
+	sub.CloseSend()
+
+	defer func() {
+		cancel()
+		<-sub.Context().Done()
+	}()
+
+	eg.Go(func() error {
+		_, err := progressui.DisplaySolveStatus(ctxGo, "test", nil, &testWriter{t}, ch)
+		return err
+	})
+
+	eg.Go(func() error {
+		_, err := bc.Solve(ctxGo, def, client.SolveOpt{}, ch)
+		return err
+	})
+	assert.NilError(t, eg.Wait())
+
+	he, err := sub.Recv()
+	assert.NilError(t, err)
+	assert.Assert(t, he != nil)
+	cancel()
+
+	// Traces for history records are recorded asynchronously, so we need to wait for it to be available.
+	if he.Record.Trace != nil {
+		return
+	}
+
+	// Split this into a new span so it doesn't clutter up the trace reporting GUI.
+	ctx, span := otel.Tracer("").Start(ctx, "Wait for trace to propagate to history record")
+	defer span.End()
+
+	t.Log("Waiting for trace to be available")
+	poll.WaitOn(t, func(logger poll.LogT) poll.Result {
+		ctx, cancel := context.WithCancel(ctx)
+		defer cancel()
+
+		sub, err := bc.ControlClient().ListenBuildHistory(ctx, &moby_buildkit_v1.BuildHistoryRequest{Ref: he.Record.Ref})
+		if err != nil {
+			return poll.Error(err)
+		}
+		sub.CloseSend()
+
+		defer func() {
+			cancel()
+			<-sub.Context().Done()
+		}()
+
+		msg, err := sub.Recv()
+		if err != nil {
+			return poll.Error(err)
+		}
+
+		if msg.Record.Ref != he.Record.Ref {
+			return poll.Error(fmt.Errorf("got incorrect history record"))
+		}
+		if msg.Record.Trace != nil {
+			return poll.Success()
+		}
+		return poll.Continue("trace not available yet")
+	}, poll.WithDelay(time.Second), poll.WithTimeout(30*time.Second))
+
+}

+ 1 - 0
vendor.mod

@@ -183,6 +183,7 @@ require (
 	github.com/tonistiigi/fsutil v0.0.0-20230629203738-36ef4d8c0dbb // indirect
 	github.com/tonistiigi/go-actions-cache v0.0.0-20220404170428-0bdeb6e1eac7 // indirect
 	github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
+	github.com/tonistiigi/vt100 v0.0.0-20210615222946-8066bb97264f // indirect
 	github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b // indirect
 	github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc // indirect
 	github.com/zmap/zlint/v3 v3.1.0 // indirect

+ 2 - 0
vendor.sum

@@ -1368,6 +1368,8 @@ github.com/tonistiigi/go-archvariant v1.0.0 h1:5LC1eDWiBNflnTF1prCiX09yfNHIxDC/a
 github.com/tonistiigi/go-archvariant v1.0.0/go.mod h1:TxFmO5VS6vMq2kvs3ht04iPXtu2rUT/erOnGFYfk5Ho=
 github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea h1:SXhTLE6pb6eld/v/cCndK0AMpt1wiVFb/YYmqB3/QG0=
 github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea/go.mod h1:WPnis/6cRcDZSUvVmezrxJPkiO87ThFYsoUiMwWNDJk=
+github.com/tonistiigi/vt100 v0.0.0-20210615222946-8066bb97264f h1:DLpt6B5oaaS8jyXHa9VA4rrZloBVPVXeCtrOsrFauxc=
+github.com/tonistiigi/vt100 v0.0.0-20210615222946-8066bb97264f/go.mod h1:ulncasL3N9uLrVann0m+CDlJKWsIAP34MPcOJF6VRvc=
 github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
 github.com/uber/jaeger-client-go v2.25.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
 github.com/uber/jaeger-lib v2.2.0+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=

+ 133 - 0
vendor/github.com/moby/buildkit/util/progress/progressui/colors.go

@@ -0,0 +1,133 @@
+package progressui
+
+import (
+	"encoding/csv"
+	"errors"
+	"strconv"
+	"strings"
+
+	"github.com/morikuni/aec"
+	"github.com/sirupsen/logrus"
+)
+
+var termColorMap = map[string]aec.ANSI{
+	"default": aec.DefaultF,
+
+	"black":   aec.BlackF,
+	"blue":    aec.BlueF,
+	"cyan":    aec.CyanF,
+	"green":   aec.GreenF,
+	"magenta": aec.MagentaF,
+	"red":     aec.RedF,
+	"white":   aec.WhiteF,
+	"yellow":  aec.YellowF,
+
+	"light-black":   aec.LightBlackF,
+	"light-blue":    aec.LightBlueF,
+	"light-cyan":    aec.LightCyanF,
+	"light-green":   aec.LightGreenF,
+	"light-magenta": aec.LightMagentaF,
+	"light-red":     aec.LightRedF,
+	"light-white":   aec.LightWhiteF,
+	"light-yellow":  aec.LightYellowF,
+}
+
+func setUserDefinedTermColors(colorsEnv string) {
+	fields := readBuildkitColorsEnv(colorsEnv)
+	if fields == nil {
+		return
+	}
+	for _, field := range fields {
+		k, v, ok := strings.Cut(field, "=")
+		if !ok || strings.Contains(v, "=") {
+			err := errors.New("A valid entry must have exactly two fields")
+			logrus.WithError(err).Warnf("Could not parse BUILDKIT_COLORS component: %s", field)
+			continue
+		}
+		k = strings.ToLower(k)
+		if c, ok := termColorMap[strings.ToLower(v)]; ok {
+			parseKeys(k, c)
+		} else if strings.Contains(v, ",") {
+			if c := readRGB(v); c != nil {
+				parseKeys(k, c)
+			}
+		} else {
+			err := errors.New("Colors must be a name from the pre-defined list or a valid 3-part RGB value")
+			logrus.WithError(err).Warnf("Unknown color value found in BUILDKIT_COLORS: %s=%s", k, v)
+		}
+	}
+}
+
+func readBuildkitColorsEnv(colorsEnv string) []string {
+	csvReader := csv.NewReader(strings.NewReader(colorsEnv))
+	csvReader.Comma = ':'
+	fields, err := csvReader.Read()
+	if err != nil {
+		logrus.WithError(err).Warnf("Could not parse BUILDKIT_COLORS. Falling back to defaults.")
+		return nil
+	}
+	return fields
+}
+
+func readRGB(v string) aec.ANSI {
+	csvReader := csv.NewReader(strings.NewReader(v))
+	fields, err := csvReader.Read()
+	if err != nil {
+		logrus.WithError(err).Warnf("Could not parse value %s as valid comma-separated RGB color. Ignoring.", v)
+		return nil
+	}
+	if len(fields) != 3 {
+		err = errors.New("A valid RGB color must have three fields")
+		logrus.WithError(err).Warnf("Could not parse value %s as valid RGB color. Ignoring.", v)
+		return nil
+	}
+	ok := isValidRGB(fields)
+	if ok {
+		p1, _ := strconv.Atoi(fields[0])
+		p2, _ := strconv.Atoi(fields[1])
+		p3, _ := strconv.Atoi(fields[2])
+		c := aec.Color8BitF(aec.NewRGB8Bit(uint8(p1), uint8(p2), uint8(p3)))
+		return c
+	}
+	return nil
+}
+
+func parseKeys(k string, c aec.ANSI) {
+	switch strings.ToLower(k) {
+	case "run":
+		colorRun = c
+	case "cancel":
+		colorCancel = c
+	case "error":
+		colorError = c
+	case "warning":
+		colorWarning = c
+	default:
+		logrus.Warnf("Unknown key found in BUILDKIT_COLORS (expected: run, cancel, error, or warning): %s", k)
+	}
+}
+
+func isValidRGB(s []string) bool {
+	for _, n := range s {
+		num, err := strconv.Atoi(n)
+		if err != nil {
+			logrus.Warnf("A field in BUILDKIT_COLORS appears to contain an RGB value that is not an integer: %s", strings.Join(s, ","))
+			return false
+		}
+		ok := isValidRGBValue(num)
+		if ok {
+			continue
+		} else {
+			logrus.Warnf("A field in BUILDKIT_COLORS appears to contain an RGB value that is not within the valid range of 0-255: %s", strings.Join(s, ","))
+			return false
+		}
+	}
+	return true
+}
+
+func isValidRGBValue(i int) bool {
+	if (i >= 0) && (i <= 255) {
+		return true
+	}
+	return false
+}

+ 910 - 0
vendor/github.com/moby/buildkit/util/progress/progressui/display.go

@@ -0,0 +1,910 @@
+package progressui
+
+import (
+	"bytes"
+	"container/ring"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/containerd/console"
+	"github.com/moby/buildkit/client"
+	"github.com/morikuni/aec"
+	digest "github.com/opencontainers/go-digest"
+	"github.com/tonistiigi/units"
+	"github.com/tonistiigi/vt100"
+	"golang.org/x/time/rate"
+)
+
+func DisplaySolveStatus(ctx context.Context, phase string, c console.Console, w io.Writer, ch chan *client.SolveStatus) ([]client.VertexWarning, error) {
+	modeConsole := c != nil
+
+	disp := &display{c: c, phase: phase}
+	printer := &textMux{w: w}
+
+	if disp.phase == "" {
+		disp.phase = "Building"
+	}
+
+	t := newTrace(w, modeConsole)
+
+	tickerTimeout := 150 * time.Millisecond
+	displayTimeout := 100 * time.Millisecond
+
+	if v := os.Getenv("TTY_DISPLAY_RATE"); v != "" {
+		if r, err := strconv.ParseInt(v, 10, 64); err == nil {
+			tickerTimeout = time.Duration(r) * time.Millisecond
+			displayTimeout = time.Duration(r) * time.Millisecond
+		}
+	}
+
+	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)
+
+	var height int
+	width, _ := disp.getSize()
+	for {
+		select {
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		case <-ticker.C:
+		case ss, ok := <-ch:
+			if ok {
+				t.update(ss, width)
+			} else {
+				done = true
+			}
+		}
+
+		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)
+			}
+		}
+	}
+}
+
+const termHeight = 6
+const termPad = 10
+
+type displayInfo struct {
+	startTime      time.Time
+	jobs           []*job
+	countTotal     int
+	countCompleted int
+}
+
+type job struct {
+	intervals   []interval
+	isCompleted bool
+	name        string
+	status      string
+	hasError    bool
+	hasWarning  bool // This is currently unused, but it's here for future use.
+	isCanceled  bool
+	vertex      *vertex
+	showTerm    bool
+}
+
+type trace struct {
+	w             io.Writer
+	startTime     *time.Time
+	localTimeDiff time.Duration
+	vertexes      []*vertex
+	byDigest      map[digest.Digest]*vertex
+	updates       map[digest.Digest]struct{}
+	modeConsole   bool
+	groups        map[string]*vertexGroup // group id -> group
+}
+
+type vertex struct {
+	*client.Vertex
+
+	statuses []*status
+	byID     map[string]*status
+	indent   string
+	index    int
+
+	logs          [][]byte
+	logsPartial   bool
+	logsOffset    int
+	logsBuffer    *ring.Ring // stores last logs to print them on error
+	prev          *client.Vertex
+	events        []string
+	lastBlockTime *time.Time
+	count         int
+	statusUpdates map[string]struct{}
+
+	warnings   []client.VertexWarning
+	warningIdx int
+
+	jobs      []*job
+	jobCached bool
+
+	term      *vt100.VT100
+	termBytes int
+	termCount int
+
+	// Interval start time in unix nano -> interval. Using a map ensures
+	// that updates for the same interval overwrite their previous updates.
+	intervals       map[int64]interval
+	mergedIntervals []interval
+
+	// whether the vertex should be hidden due to being in a progress group
+	// that doesn't have any non-weak members that have started
+	hidden bool
+}
+
+func (v *vertex) update(c int) {
+	if v.count == 0 {
+		now := time.Now()
+		v.lastBlockTime = &now
+	}
+	v.count += c
+}
+
+func (v *vertex) mostRecentInterval() *interval {
+	if v.isStarted() {
+		ival := v.mergedIntervals[len(v.mergedIntervals)-1]
+		return &ival
+	}
+	return nil
+}
+
+func (v *vertex) isStarted() bool {
+	return len(v.mergedIntervals) > 0
+}
+
+func (v *vertex) isCompleted() bool {
+	if ival := v.mostRecentInterval(); ival != nil {
+		return ival.stop != nil
+	}
+	return false
+}
+
+type vertexGroup struct {
+	*vertex
+	subVtxs map[digest.Digest]client.Vertex
+}
+
+func (vg *vertexGroup) refresh() (changed, newlyStarted, newlyRevealed bool) {
+	newVtx := *vg.Vertex
+	newVtx.Cached = true
+	alreadyStarted := vg.isStarted()
+	wasHidden := vg.hidden
+	for _, subVtx := range vg.subVtxs {
+		if subVtx.Started != nil {
+			newInterval := interval{
+				start: subVtx.Started,
+				stop:  subVtx.Completed,
+			}
+			prevInterval := vg.intervals[subVtx.Started.UnixNano()]
+			if !newInterval.isEqual(prevInterval) {
+				changed = true
+			}
+			if !alreadyStarted {
+				newlyStarted = true
+			}
+			vg.intervals[subVtx.Started.UnixNano()] = newInterval
+
+			if !subVtx.ProgressGroup.Weak {
+				vg.hidden = false
+			}
+		}
+
+		// Group is considered cached iff all subvtxs are cached
+		newVtx.Cached = newVtx.Cached && subVtx.Cached
+
+		// Group error is set to the first error found in subvtxs, if any
+		if newVtx.Error == "" {
+			newVtx.Error = subVtx.Error
+		} else {
+			vg.hidden = false
+		}
+	}
+
+	if vg.Cached != newVtx.Cached {
+		changed = true
+	}
+	if vg.Error != newVtx.Error {
+		changed = true
+	}
+	vg.Vertex = &newVtx
+
+	if !vg.hidden && wasHidden {
+		changed = true
+		newlyRevealed = true
+	}
+
+	var ivals []interval
+	for _, ival := range vg.intervals {
+		ivals = append(ivals, ival)
+	}
+	vg.mergedIntervals = mergeIntervals(ivals)
+
+	return changed, newlyStarted, newlyRevealed
+}
+
+type interval struct {
+	start *time.Time
+	stop  *time.Time
+}
+
+func (ival interval) duration() time.Duration {
+	if ival.start == nil {
+		return 0
+	}
+	if ival.stop == nil {
+		return time.Since(*ival.start)
+	}
+	return ival.stop.Sub(*ival.start)
+}
+
+func (ival interval) isEqual(other interval) (isEqual bool) {
+	return equalTimes(ival.start, other.start) && equalTimes(ival.stop, other.stop)
+}
+
+func equalTimes(t1, t2 *time.Time) bool {
+	if t2 == nil {
+		return t1 == nil
+	}
+	if t1 == nil {
+		return false
+	}
+	return t1.Equal(*t2)
+}
+
+// mergeIntervals takes a slice of (start, stop) pairs and returns a slice where
+// any intervals that overlap in time are combined into a single interval. If an
+// interval's stop time is nil, it is treated as positive infinity and consumes
+// any intervals after it. Intervals with nil start times are ignored and not
+// returned.
+func mergeIntervals(intervals []interval) []interval {
+	// remove any intervals that have not started
+	var filtered []interval
+	for _, interval := range intervals {
+		if interval.start != nil {
+			filtered = append(filtered, interval)
+		}
+	}
+	intervals = filtered
+
+	if len(intervals) == 0 {
+		return nil
+	}
+
+	// sort intervals by start time
+	sort.Slice(intervals, func(i, j int) bool {
+		return intervals[i].start.Before(*intervals[j].start)
+	})
+
+	var merged []interval
+	cur := intervals[0]
+	for i := 1; i < len(intervals); i++ {
+		next := intervals[i]
+		if cur.stop == nil {
+			// if cur doesn't stop, all intervals after it will be merged into it
+			merged = append(merged, cur)
+			return merged
+		}
+		if cur.stop.Before(*next.start) {
+			// if cur stops before next starts, no intervals after cur will be
+			// merged into it; cur stands on its own
+			merged = append(merged, cur)
+			cur = next
+			continue
+		}
+		if next.stop == nil {
+			// cur and next partially overlap, but next also never stops, so all
+			// subsequent intervals will be merged with both cur and next
+			merged = append(merged, interval{
+				start: cur.start,
+				stop:  nil,
+			})
+			return merged
+		}
+		if cur.stop.After(*next.stop) || cur.stop.Equal(*next.stop) {
+			// cur fully subsumes next
+			continue
+		}
+		// cur partially overlaps with next, merge them together into cur
+		cur = interval{
+			start: cur.start,
+			stop:  next.stop,
+		}
+	}
+	// append anything we are left with
+	merged = append(merged, cur)
+	return merged
+}
+
+type status struct {
+	*client.VertexStatus
+}
+
+func newTrace(w io.Writer, modeConsole bool) *trace {
+	return &trace{
+		byDigest:    make(map[digest.Digest]*vertex),
+		updates:     make(map[digest.Digest]struct{}),
+		w:           w,
+		modeConsole: modeConsole,
+		groups:      make(map[string]*vertexGroup),
+	}
+}
+
+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
+	}
+
+	var old client.Vertex
+	vtx := t.byDigest[v.Digest]
+	if v := vtx.prev; v != nil {
+		old = *v
+	}
+
+	changed := false
+	if v.Digest != old.Digest {
+		changed = true
+	}
+	if v.Name != old.Name {
+		changed = true
+	}
+	if v.Started != old.Started {
+		if v.Started != nil && old.Started == nil || !v.Started.Equal(*old.Started) {
+			changed = true
+		}
+	}
+	if v.Completed != old.Completed && v.Completed != nil {
+		changed = true
+	}
+	if v.Cached != old.Cached {
+		changed = true
+	}
+	if v.Error != old.Error {
+		changed = true
+	}
+
+	if changed {
+		vtx.update(1)
+		t.updates[v.Digest] = struct{}{}
+	}
+
+	t.byDigest[v.Digest].prev = v
+}
+
+func (t *trace) update(s *client.SolveStatus, termWidth int) {
+	seenGroups := make(map[string]struct{})
+	var groups []string
+	for _, v := range s.Vertexes {
+		if t.startTime == nil {
+			t.startTime = v.Started
+		}
+		if v.ProgressGroup != nil {
+			group, ok := t.groups[v.ProgressGroup.Id]
+			if !ok {
+				group = &vertexGroup{
+					vertex: &vertex{
+						Vertex: &client.Vertex{
+							Digest: digest.Digest(v.ProgressGroup.Id),
+							Name:   v.ProgressGroup.Name,
+						},
+						byID:          make(map[string]*status),
+						statusUpdates: make(map[string]struct{}),
+						intervals:     make(map[int64]interval),
+						hidden:        true,
+					},
+					subVtxs: make(map[digest.Digest]client.Vertex),
+				}
+				if t.modeConsole {
+					group.term = vt100.NewVT100(termHeight, termWidth-termPad)
+				}
+				t.groups[v.ProgressGroup.Id] = group
+				t.byDigest[group.Digest] = group.vertex
+			}
+			if _, ok := seenGroups[v.ProgressGroup.Id]; !ok {
+				groups = append(groups, v.ProgressGroup.Id)
+				seenGroups[v.ProgressGroup.Id] = struct{}{}
+			}
+			group.subVtxs[v.Digest] = *v
+			t.byDigest[v.Digest] = group.vertex
+			continue
+		}
+		prev, ok := t.byDigest[v.Digest]
+		if !ok {
+			t.byDigest[v.Digest] = &vertex{
+				byID:          make(map[string]*status),
+				statusUpdates: make(map[string]struct{}),
+				intervals:     make(map[int64]interval),
+			}
+			if t.modeConsole {
+				t.byDigest[v.Digest].term = vt100.NewVT100(termHeight, termWidth-termPad)
+			}
+		}
+		t.triggerVertexEvent(v)
+		if v.Started != nil && (prev == nil || !prev.isStarted()) {
+			if t.localTimeDiff == 0 {
+				t.localTimeDiff = time.Since(*v.Started)
+			}
+			t.vertexes = append(t.vertexes, t.byDigest[v.Digest])
+		}
+		// allow a duplicate initial vertex that shouldn't reset state
+		if !(prev != nil && prev.isStarted() && v.Started == nil) {
+			t.byDigest[v.Digest].Vertex = v
+		}
+		if v.Started != nil {
+			t.byDigest[v.Digest].intervals[v.Started.UnixNano()] = interval{
+				start: v.Started,
+				stop:  v.Completed,
+			}
+			var ivals []interval
+			for _, ival := range t.byDigest[v.Digest].intervals {
+				ivals = append(ivals, ival)
+			}
+			t.byDigest[v.Digest].mergedIntervals = mergeIntervals(ivals)
+		}
+		t.byDigest[v.Digest].jobCached = false
+	}
+	for _, groupID := range groups {
+		group := t.groups[groupID]
+		changed, newlyStarted, newlyRevealed := group.refresh()
+		if newlyStarted {
+			if t.localTimeDiff == 0 {
+				t.localTimeDiff = time.Since(*group.mergedIntervals[0].start)
+			}
+		}
+		if group.hidden {
+			continue
+		}
+		if newlyRevealed {
+			t.vertexes = append(t.vertexes, group.vertex)
+		}
+		if changed {
+			group.update(1)
+			t.updates[group.Digest] = struct{}{}
+		}
+		group.jobCached = false
+	}
+	for _, s := range s.Statuses {
+		v, ok := t.byDigest[s.Vertex]
+		if !ok {
+			continue // shouldn't happen
+		}
+		v.jobCached = false
+		prev, ok := v.byID[s.ID]
+		if !ok {
+			v.byID[s.ID] = &status{VertexStatus: s}
+		}
+		if s.Started != nil && (prev == nil || prev.Started == nil) {
+			v.statuses = append(v.statuses, v.byID[s.ID])
+		}
+		v.byID[s.ID].VertexStatus = s
+		v.statusUpdates[s.ID] = struct{}{}
+		t.updates[v.Digest] = struct{}{}
+		v.update(1)
+	}
+	for _, w := range s.Warnings {
+		v, ok := t.byDigest[w.Vertex]
+		if !ok {
+			continue // shouldn't happen
+		}
+		v.warnings = append(v.warnings, *w)
+		v.update(1)
+	}
+	for _, l := range s.Logs {
+		v, ok := t.byDigest[l.Vertex]
+		if !ok {
+			continue // shouldn't happen
+		}
+		v.jobCached = false
+		if v.term != nil {
+			if v.term.Width != termWidth {
+				v.term.Resize(termHeight, termWidth-termPad)
+			}
+			v.termBytes += len(l.Data)
+			v.term.Write(l.Data) // error unhandled on purpose. don't trust vt100
+		}
+		i := 0
+		complete := split(l.Data, byte('\n'), func(dt []byte) {
+			if v.logsPartial && len(v.logs) != 0 && i == 0 {
+				v.logs[len(v.logs)-1] = append(v.logs[len(v.logs)-1], dt...)
+			} else {
+				ts := time.Duration(0)
+				if ival := v.mostRecentInterval(); ival != nil {
+					ts = l.Timestamp.Sub(*ival.start)
+				}
+				prec := 1
+				sec := ts.Seconds()
+				if sec < 10 {
+					prec = 3
+				} else if sec < 100 {
+					prec = 2
+				}
+				v.logs = append(v.logs, []byte(fmt.Sprintf("#%d %s %s", v.index, fmt.Sprintf("%.[2]*[1]f", sec, prec), dt)))
+			}
+			i++
+		})
+		v.logsPartial = !complete
+		t.updates[v.Digest] = struct{}{}
+		v.update(1)
+	}
+}
+
+func (t *trace) printErrorLogs(f io.Writer) {
+	for _, v := range t.vertexes {
+		if v.Error != "" && !strings.HasSuffix(v.Error, context.Canceled.Error()) {
+			fmt.Fprintln(f, "------")
+			fmt.Fprintf(f, " > %s:\n", v.Name)
+			// tty keeps original logs
+			for _, l := range v.logs {
+				f.Write(l)
+				fmt.Fprintln(f)
+			}
+			// printer keeps last logs buffer
+			if v.logsBuffer != nil {
+				for i := 0; i < v.logsBuffer.Len(); i++ {
+					if v.logsBuffer.Value != nil {
+						fmt.Fprintln(f, string(v.logsBuffer.Value.([]byte)))
+					}
+					v.logsBuffer = v.logsBuffer.Next()
+				}
+			}
+			fmt.Fprintln(f, "------")
+		}
+	}
+}
+
+func (t *trace) displayInfo() (d displayInfo) {
+	d.startTime = time.Now()
+	if t.startTime != nil {
+		d.startTime = t.startTime.Add(t.localTimeDiff)
+	}
+	d.countTotal = len(t.byDigest)
+	for _, v := range t.byDigest {
+		if v.ProgressGroup != nil || v.hidden {
+			// don't count vtxs in a group, they are merged into a single vtx
+			d.countTotal--
+			continue
+		}
+		if v.isCompleted() {
+			d.countCompleted++
+		}
+	}
+
+	for _, v := range t.vertexes {
+		if v.jobCached {
+			d.jobs = append(d.jobs, v.jobs...)
+			continue
+		}
+		var jobs []*job
+		j := &job{
+			name:        strings.Replace(v.Name, "\t", " ", -1),
+			vertex:      v,
+			isCompleted: true,
+		}
+		for _, ival := range v.intervals {
+			j.intervals = append(j.intervals, interval{
+				start: addTime(ival.start, t.localTimeDiff),
+				stop:  addTime(ival.stop, t.localTimeDiff),
+			})
+			if ival.stop == nil {
+				j.isCompleted = false
+			}
+		}
+		j.intervals = mergeIntervals(j.intervals)
+		if v.Error != "" {
+			if strings.HasSuffix(v.Error, context.Canceled.Error()) {
+				j.isCanceled = true
+				j.name = "CANCELED " + j.name
+			} else {
+				j.hasError = true
+				j.name = "ERROR " + j.name
+			}
+		}
+		if v.Cached {
+			j.name = "CACHED " + j.name
+		}
+		j.name = v.indent + j.name
+		jobs = append(jobs, j)
+		for _, s := range v.statuses {
+			j := &job{
+				intervals: []interval{{
+					start: addTime(s.Started, t.localTimeDiff),
+					stop:  addTime(s.Completed, t.localTimeDiff),
+				}},
+				isCompleted: s.Completed != nil,
+				name:        v.indent + "=> " + s.ID,
+			}
+			if s.Total != 0 {
+				j.status = fmt.Sprintf("%.2f / %.2f", units.Bytes(s.Current), units.Bytes(s.Total))
+			} else if s.Current != 0 {
+				j.status = fmt.Sprintf("%.2f", units.Bytes(s.Current))
+			}
+			jobs = append(jobs, j)
+		}
+		for _, w := range v.warnings {
+			msg := "WARN: " + string(w.Short)
+			var mostRecentInterval interval
+			if ival := v.mostRecentInterval(); ival != nil {
+				mostRecentInterval = *ival
+			}
+			j := &job{
+				intervals: []interval{{
+					start: addTime(mostRecentInterval.start, t.localTimeDiff),
+					stop:  addTime(mostRecentInterval.stop, t.localTimeDiff),
+				}},
+				name:       msg,
+				isCanceled: true,
+			}
+			jobs = append(jobs, j)
+		}
+		d.jobs = append(d.jobs, jobs...)
+		v.jobs = jobs
+		v.jobCached = true
+	}
+
+	return d
+}
+
+func split(dt []byte, sep byte, fn func([]byte)) bool {
+	if len(dt) == 0 {
+		return false
+	}
+	for {
+		if len(dt) == 0 {
+			return true
+		}
+		idx := bytes.IndexByte(dt, sep)
+		if idx == -1 {
+			fn(dt)
+			return false
+		}
+		fn(dt[:idx])
+		dt = dt[idx+1:]
+	}
+}
+
+func addTime(tm *time.Time, d time.Duration) *time.Time {
+	if tm == nil {
+		return nil
+	}
+	t := (*tm).Add(d)
+	return &t
+}
+
+type display struct {
+	c         console.Console
+	phase     string
+	lineCount int
+	repeated  bool
+}
+
+func (disp *display) getSize() (int, int) {
+	width := 80
+	height := 10
+	if disp.c != nil {
+		size, err := disp.c.Size()
+		if err == nil && size.Width > 0 && size.Height > 0 {
+			width = int(size.Width)
+			height = int(size.Height)
+		}
+	}
+	return width, height
+}
+
+func setupTerminals(jobs []*job, height int, all bool) []*job {
+	var candidates []*job
+	numInUse := 0
+	for _, j := range jobs {
+		if j.vertex != nil && j.vertex.termBytes > 0 && !j.isCompleted {
+			candidates = append(candidates, j)
+		}
+		if !j.isCompleted {
+			numInUse++
+		}
+	}
+	sort.Slice(candidates, func(i, j int) bool {
+		idxI := candidates[i].vertex.termBytes + candidates[i].vertex.termCount*50
+		idxJ := candidates[j].vertex.termBytes + candidates[j].vertex.termCount*50
+		return idxI > idxJ
+	})
+
+	numFree := height - 2 - numInUse
+	numToHide := 0
+	termLimit := termHeight + 3
+
+	for i := 0; numFree > termLimit && i < len(candidates); i++ {
+		candidates[i].showTerm = true
+		numToHide += candidates[i].vertex.term.UsedHeight()
+		numFree -= termLimit
+	}
+
+	if !all {
+		jobs = wrapHeight(jobs, height-2-numToHide)
+	}
+
+	return jobs
+}
+
+func (disp *display) print(d displayInfo, width, height int, all bool) {
+	// this output is inspired by Buck
+	d.jobs = setupTerminals(d.jobs, height, all)
+	b := aec.EmptyBuilder
+	for i := 0; i <= disp.lineCount; i++ {
+		b = b.Up(1)
+	}
+	if !disp.repeated {
+		b = b.Down(1)
+	}
+	disp.repeated = true
+	fmt.Fprint(disp.c, b.Column(0).ANSI)
+
+	statusStr := ""
+	if d.countCompleted > 0 && d.countCompleted == d.countTotal && all {
+		statusStr = "FINISHED"
+	}
+
+	fmt.Fprint(disp.c, aec.Hide)
+	defer fmt.Fprint(disp.c, aec.Show)
+
+	out := fmt.Sprintf("[+] %s %.1fs (%d/%d) %s", disp.phase, time.Since(d.startTime).Seconds(), d.countCompleted, d.countTotal, statusStr)
+	out = align(out, "", width)
+	fmt.Fprintln(disp.c, out)
+	lineCount := 0
+	for _, j := range d.jobs {
+		if len(j.intervals) == 0 {
+			continue
+		}
+		var dt float64
+		for _, ival := range j.intervals {
+			dt += ival.duration().Seconds()
+		}
+		if dt < 0.05 {
+			dt = 0
+		}
+		pfx := " => "
+		timer := fmt.Sprintf(" %3.1fs\n", dt)
+		status := j.status
+		showStatus := false
+
+		left := width - len(pfx) - len(timer) - 1
+		if status != "" {
+			if left+len(status) > 20 {
+				showStatus = true
+				left -= len(status) + 1
+			}
+		}
+		if left < 12 { // too small screen to show progress
+			continue
+		}
+		name := j.name
+		if len(name) > left {
+			name = name[:left]
+		}
+
+		out := pfx + name
+		if showStatus {
+			out += " " + status
+		}
+
+		out = align(out, timer, width)
+		if j.isCompleted {
+			color := colorRun
+			if j.isCanceled {
+				color = colorCancel
+			} else if j.hasError {
+				color = colorError
+			} else if j.hasWarning {
+				// This is currently unused, but it's here for future use.
+				color = colorWarning
+			}
+			if color != nil {
+				out = aec.Apply(out, color)
+			}
+		}
+		fmt.Fprint(disp.c, out)
+		lineCount++
+		if j.showTerm {
+			term := j.vertex.term
+			term.Resize(termHeight, width-termPad)
+			for _, l := range term.Content {
+				if !isEmpty(l) {
+					out := aec.Apply(fmt.Sprintf(" => => # %s\n", string(l)), aec.Faint)
+					fmt.Fprint(disp.c, out)
+					lineCount++
+				}
+			}
+			j.vertex.termCount++
+			j.showTerm = false
+		}
+	}
+	// override previous content
+	if diff := disp.lineCount - lineCount; diff > 0 {
+		for i := 0; i < diff; i++ {
+			fmt.Fprintln(disp.c, strings.Repeat(" ", width))
+		}
+		fmt.Fprint(disp.c, aec.EmptyBuilder.Up(uint(diff)).Column(0).ANSI)
+	}
+	disp.lineCount = lineCount
+}
+
+func isEmpty(l []rune) bool {
+	for _, r := range l {
+		if r != ' ' {
+			return false
+		}
+	}
+	return true
+}
+
+func align(l, r string, w int) string {
+	return fmt.Sprintf("%-[2]*[1]s %[3]s", l, w-len(r)-1, r)
+}
+
+func wrapHeight(j []*job, limit int) []*job {
+	if limit < 0 {
+		return nil
+	}
+	var wrapped []*job
+	wrapped = append(wrapped, j...)
+	if len(j) > limit {
+		wrapped = wrapped[len(j)-limit:]
+
+		// wrap things around if incomplete jobs were cut
+		var invisible []*job
+		for _, j := range j[:len(j)-limit] {
+			if !j.isCompleted {
+				invisible = append(invisible, j)
+			}
+		}
+
+		if l := len(invisible); l > 0 {
+			rewrapped := make([]*job, 0, len(wrapped))
+			for _, j := range wrapped {
+				if !j.isCompleted || l <= 0 {
+					rewrapped = append(rewrapped, j)
+				}
+				l--
+			}
+			freespace := len(wrapped) - len(rewrapped)
+			wrapped = append(invisible[len(invisible)-freespace:], rewrapped...)
+		}
+	}
+	return wrapped
+}

+ 37 - 0
vendor/github.com/moby/buildkit/util/progress/progressui/init.go

@@ -0,0 +1,37 @@
+package progressui
+
+import (
+	"os"
+	"runtime"
+
+	"github.com/morikuni/aec"
+)
+
+var colorRun aec.ANSI
+var colorCancel aec.ANSI
+var colorWarning aec.ANSI
+var colorError aec.ANSI
+
+func init() {
+	// As recommended on https://no-color.org/
+	if v := os.Getenv("NO_COLOR"); v != "" {
+		// nil values will result in no ANSI color codes being emitted.
+		return
+	} else if runtime.GOOS == "windows" {
+		colorRun = termColorMap["cyan"]
+		colorCancel = termColorMap["yellow"]
+		colorWarning = termColorMap["yellow"]
+		colorError = termColorMap["red"]
+	} else {
+		colorRun = termColorMap["blue"]
+		colorCancel = termColorMap["yellow"]
+		colorWarning = termColorMap["yellow"]
+		colorError = termColorMap["red"]
+	}
+
+	// Loosely based on the standard set by Linux LS_COLORS.
+	if _, ok := os.LookupEnv("BUILDKIT_COLORS"); ok {
+		envColorString := os.Getenv("BUILDKIT_COLORS")
+		setUserDefinedTermColors(envColorString)
+	}
+}

+ 333 - 0
vendor/github.com/moby/buildkit/util/progress/progressui/printer.go

@@ -0,0 +1,333 @@
+package progressui
+
+import (
+	"container/ring"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"sort"
+	"strings"
+	"time"
+
+	digest "github.com/opencontainers/go-digest"
+	"github.com/tonistiigi/units"
+)
+
+const antiFlicker = 5 * time.Second
+const maxDelay = 10 * time.Second
+const minTimeDelta = 5 * time.Second
+const minProgressDelta = 0.05 // %
+
+const logsBufferSize = 10
+
+type lastStatus struct {
+	Current   int64
+	Timestamp time.Time
+}
+
+type textMux struct {
+	w         io.Writer
+	current   digest.Digest
+	last      map[string]lastStatus
+	notFirst  bool
+	nextIndex int
+}
+
+func (p *textMux) printVtx(t *trace, dgst digest.Digest) {
+	if p.last == nil {
+		p.last = make(map[string]lastStatus)
+	}
+
+	v, ok := t.byDigest[dgst]
+	if !ok {
+		return
+	}
+
+	if v.index == 0 {
+		p.nextIndex++
+		v.index = p.nextIndex
+	}
+
+	if dgst != p.current {
+		if p.current != "" {
+			old := t.byDigest[p.current]
+			if old.logsPartial {
+				fmt.Fprintln(p.w, "")
+			}
+			old.logsOffset = 0
+			old.count = 0
+			fmt.Fprintf(p.w, "#%d ...\n", old.index)
+		}
+
+		if p.notFirst {
+			fmt.Fprintln(p.w, "")
+		} else {
+			p.notFirst = true
+		}
+
+		if os.Getenv("PROGRESS_NO_TRUNC") == "0" {
+			fmt.Fprintf(p.w, "#%d %s\n", v.index, limitString(v.Name, 72))
+		} else {
+			fmt.Fprintf(p.w, "#%d %s\n", v.index, v.Name)
+		}
+	}
+
+	if len(v.events) != 0 {
+		v.logsOffset = 0
+	}
+	for _, ev := range v.events {
+		fmt.Fprintf(p.w, "#%d %s\n", v.index, ev)
+	}
+	v.events = v.events[:0]
+
+	isOpenStatus := false // remote cache loading can currently produce status updates without active vertex
+	for _, s := range v.statuses {
+		if _, ok := v.statusUpdates[s.ID]; ok {
+			doPrint := true
+
+			if last, ok := p.last[s.ID]; ok && s.Completed == nil {
+				var progressDelta float64
+				if s.Total > 0 {
+					progressDelta = float64(s.Current-last.Current) / float64(s.Total)
+				}
+				timeDelta := s.Timestamp.Sub(last.Timestamp)
+				if progressDelta < minProgressDelta && timeDelta < minTimeDelta {
+					doPrint = false
+				}
+			}
+
+			if !doPrint {
+				continue
+			}
+
+			p.last[s.ID] = lastStatus{
+				Timestamp: s.Timestamp,
+				Current:   s.Current,
+			}
+
+			var bytes string
+			if s.Total != 0 {
+				bytes = fmt.Sprintf(" %.2f / %.2f", units.Bytes(s.Current), units.Bytes(s.Total))
+			} else if s.Current != 0 {
+				bytes = fmt.Sprintf(" %.2f", units.Bytes(s.Current))
+			}
+			var tm string
+			endTime := s.Timestamp
+			if s.Completed != nil {
+				endTime = *s.Completed
+			}
+			if s.Started != nil {
+				diff := endTime.Sub(*s.Started).Seconds()
+				if diff > 0.01 {
+					tm = fmt.Sprintf(" %.1fs", diff)
+				}
+			}
+			if s.Completed != nil {
+				tm += " done"
+			} else {
+				isOpenStatus = true
+			}
+			fmt.Fprintf(p.w, "#%d %s%s%s\n", v.index, s.ID, bytes, tm)
+		}
+	}
+	v.statusUpdates = map[string]struct{}{}
+
+	for _, w := range v.warnings[v.warningIdx:] {
+		fmt.Fprintf(p.w, "#%d WARN: %s\n", v.index, w.Short)
+		v.warningIdx++
+	}
+
+	for i, l := range v.logs {
+		if i == 0 {
+			l = l[v.logsOffset:]
+		}
+		fmt.Fprintf(p.w, "%s", []byte(l))
+		if i != len(v.logs)-1 || !v.logsPartial {
+			fmt.Fprintln(p.w, "")
+		}
+		if v.logsBuffer == nil {
+			v.logsBuffer = ring.New(logsBufferSize)
+		}
+		v.logsBuffer.Value = l
+		if !v.logsPartial {
+			v.logsBuffer = v.logsBuffer.Next()
+		}
+	}
+
+	if len(v.logs) > 0 {
+		if v.logsPartial {
+			v.logs = v.logs[len(v.logs)-1:]
+			v.logsOffset = len(v.logs[0])
+		} else {
+			v.logs = nil
+			v.logsOffset = 0
+		}
+	}
+
+	p.current = dgst
+	if v.isCompleted() && !isOpenStatus {
+		p.current = ""
+		v.count = 0
+
+		if v.logsPartial {
+			fmt.Fprintln(p.w, "")
+		}
+		if v.Error != "" {
+			if strings.HasSuffix(v.Error, context.Canceled.Error()) {
+				fmt.Fprintf(p.w, "#%d CANCELED\n", v.index)
+			} else {
+				fmt.Fprintf(p.w, "#%d ERROR: %s\n", v.index, v.Error)
+			}
+		} else if v.Cached {
+			fmt.Fprintf(p.w, "#%d CACHED\n", v.index)
+		} else {
+			tm := ""
+			var ivals []interval
+			for _, ival := range v.intervals {
+				ivals = append(ivals, ival)
+			}
+			ivals = mergeIntervals(ivals)
+			if len(ivals) > 0 {
+				var dt float64
+				for _, ival := range ivals {
+					dt += ival.duration().Seconds()
+				}
+				tm = fmt.Sprintf(" %.1fs", dt)
+			}
+			fmt.Fprintf(p.w, "#%d DONE%s\n", v.index, tm)
+		}
+	}
+
+	delete(t.updates, dgst)
+}
+
+func sortCompleted(t *trace, m map[digest.Digest]struct{}) []digest.Digest {
+	out := make([]digest.Digest, 0, len(m))
+	for k := range m {
+		out = append(out, k)
+	}
+	sort.Slice(out, func(i, j int) bool {
+		vtxi := t.byDigest[out[i]]
+		vtxj := t.byDigest[out[j]]
+		return vtxi.mostRecentInterval().stop.Before(*vtxj.mostRecentInterval().stop)
+	})
+	return out
+}
+
+func (p *textMux) print(t *trace) {
+	completed := map[digest.Digest]struct{}{}
+	rest := map[digest.Digest]struct{}{}
+
+	for dgst := range t.updates {
+		v, ok := t.byDigest[dgst]
+		if !ok {
+			continue
+		}
+		if v.ProgressGroup != nil || v.hidden {
+			// skip vtxs in a group (they are merged into a single vtx) and hidden ones
+			continue
+		}
+		if v.isCompleted() {
+			completed[dgst] = struct{}{}
+		} else {
+			rest[dgst] = struct{}{}
+		}
+	}
+
+	current := p.current
+
+	// items that have completed need to be printed first
+	if _, ok := completed[current]; ok {
+		p.printVtx(t, current)
+	}
+
+	for _, dgst := range sortCompleted(t, completed) {
+		if dgst != current {
+			p.printVtx(t, dgst)
+		}
+	}
+
+	if len(rest) == 0 {
+		if current != "" {
+			if v := t.byDigest[current]; v.isStarted() && !v.isCompleted() {
+				return
+			}
+		}
+		// make any open vertex active
+		for dgst, v := range t.byDigest {
+			if v.isStarted() && !v.isCompleted() && v.ProgressGroup == nil && !v.hidden {
+				p.printVtx(t, dgst)
+				return
+			}
+		}
+		return
+	}
+
+	// now print the active one
+	if _, ok := rest[current]; ok {
+		p.printVtx(t, current)
+	}
+
+	stats := map[digest.Digest]*vtxStat{}
+	now := time.Now()
+	sum := 0.0
+	var max digest.Digest
+	if current != "" {
+		rest[current] = struct{}{}
+	}
+	for dgst := range rest {
+		v, ok := t.byDigest[dgst]
+		if !ok {
+			continue
+		}
+		if v.lastBlockTime == nil {
+			// shouldn't happen, but not worth crashing over
+			continue
+		}
+		tm := now.Sub(*v.lastBlockTime)
+		speed := float64(v.count) / tm.Seconds()
+		overLimit := tm > maxDelay && dgst != current
+		stats[dgst] = &vtxStat{blockTime: tm, speed: speed, overLimit: overLimit}
+		sum += speed
+		if overLimit || max == "" || stats[max].speed < speed {
+			max = dgst
+		}
+	}
+	for dgst := range stats {
+		stats[dgst].share = stats[dgst].speed / sum
+	}
+
+	if _, ok := completed[current]; ok || current == "" {
+		p.printVtx(t, max)
+		return
+	}
+
+	// show items that were hidden
+	for dgst := range rest {
+		if stats[dgst].overLimit {
+			p.printVtx(t, dgst)
+			return
+		}
+	}
+
+	// fair split between vertexes
+	if 1.0/(1.0-stats[current].share)*antiFlicker.Seconds() < stats[current].blockTime.Seconds() {
+		p.printVtx(t, max)
+		return
+	}
+}
+
+type vtxStat struct {
+	blockTime time.Duration
+	speed     float64
+	share     float64
+	overLimit bool
+}
+
+func limitString(s string, l int) string {
+	if len(s) > l {
+		return s[:l] + "..."
+	}
+	return s
+}

+ 1 - 0
vendor/github.com/tonistiigi/vt100/.travis.yml

@@ -0,0 +1 @@
+language: go

+ 22 - 0
vendor/github.com/tonistiigi/vt100/LICENSE

@@ -0,0 +1,22 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 James Aguilar
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+

+ 30 - 0
vendor/github.com/tonistiigi/vt100/README.md

@@ -0,0 +1,30 @@
+#VT100
+
+[![GoDoc](https://godoc.org/github.com/tonistiigi/vt100?status.svg)](https://godoc.org/github.com/tonistiigi/vt100)
+
+This project was based on [jaguilar/vt100](https://github.com/jaguilar/vt100)
+
+This is a vt100 screen reader. It seems to do a pretty
+decent job of parsing the nethack input stream, which
+is all I want it for anyway.
+
+Here is a screenshot of the HTML-formatted screen data:
+
+![](_readme/screencap.png)
+
+The features we currently support:
+
+* Cursor movement
+* Erasing
+* Many of the text properties -- underline, inverse, blink, etc.
+* Sixteen colors
+* Cursor saving and unsaving
+* UTF-8
+* Scrolling
+
+Not currently supported (and no plans to support):
+
+* Prompts
+* Other cooked mode features
+
+The API is not stable! This is a v0 package.

+ 288 - 0
vendor/github.com/tonistiigi/vt100/command.go

@@ -0,0 +1,288 @@
+package vt100
+
+import (
+	"errors"
+	"expvar"
+	"fmt"
+	"image/color"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+// UnsupportedError indicates that we parsed an operation that this
+// terminal does not implement. Such errors indicate that the client
+// program asked us to perform an action that we don't know how to.
+// It MAY be safe to continue trying to do additional operations.
+// This is a distinct category of errors from things we do know how
+// to do, but are badly encoded, or errors from the underlying io.RuneScanner
+// that we're reading commands from.
+type UnsupportedError struct {
+	error
+}
+
+var (
+	supportErrors = expvar.NewMap("vt100-unsupported-operations")
+)
+
+func supportError(e error) error {
+	supportErrors.Add(e.Error(), 1)
+	return UnsupportedError{e}
+}
+
+// Command is a type of object that the terminal can process to perform
+// an update.
+type Command interface {
+	display(v *VT100) error
+}
+
+// runeCommand is a simple command that just writes a rune
+// to the current cell and advances the cursor.
+type runeCommand rune
+
+func (r runeCommand) display(v *VT100) error {
+	v.put(rune(r))
+	return nil
+}
+
+// escapeCommand is a control sequence command. It includes a variety
+// of control and escape sequences that move and modify the cursor
+// or the terminal.
+type escapeCommand struct {
+	cmd  rune
+	args string
+}
+
+func (c escapeCommand) String() string {
+	return fmt.Sprintf("[%q %U](%v)", c.cmd, c.cmd, c.args)
+}
+
+type intHandler func(*VT100, []int) error
+
+var (
+	// intHandlers are handlers for which all arguments are numbers.
+	// This is most of them -- all the ones that we process. Eventually,
+	// we may add handlers that support non-int args. Those handlers
+	// will instead receive []string, and they'll have to choose on their
+	// own how they might be parsed.
+	intHandlers = map[rune]intHandler{
+		's': save,
+		'7': save,
+		'u': unsave,
+		'8': unsave,
+		'A': relativeMove(-1, 0),
+		'B': relativeMove(1, 0),
+		'C': relativeMove(0, 1),
+		'D': relativeMove(0, -1),
+		'K': eraseColumns,
+		'J': eraseLines,
+		'H': home,
+		'f': home,
+		'm': updateAttributes,
+	}
+)
+
+func save(v *VT100, _ []int) error {
+	v.save()
+	return nil
+}
+
+func unsave(v *VT100, _ []int) error {
+	v.unsave()
+	return nil
+}
+
+var (
+	codeColors = []color.RGBA{
+		Black,
+		Red,
+		Green,
+		Yellow,
+		Blue,
+		Magenta,
+		Cyan,
+		White,
+		{}, // Not used.
+		DefaultColor,
+	}
+)
+
+// A command to update the attributes of the cursor based on the arg list.
+func updateAttributes(v *VT100, args []int) error {
+	f := &v.Cursor.F
+
+	var unsupported []int
+	for _, x := range args {
+		switch x {
+		case 0:
+			*f = Format{}
+		case 1:
+			f.Intensity = Bright
+		case 2:
+			f.Intensity = Dim
+		case 22:
+			f.Intensity = Normal
+		case 4:
+			f.Underscore = true
+		case 24:
+			f.Underscore = false
+		case 5, 6:
+			f.Blink = true // We don't distinguish between blink speeds.
+		case 25:
+			f.Blink = false
+		case 7:
+			f.Inverse = true
+		case 27:
+			f.Inverse = false
+		case 8:
+			f.Conceal = true
+		case 28:
+			f.Conceal = false
+		case 30, 31, 32, 33, 34, 35, 36, 37, 39:
+			f.Fg = codeColors[x-30]
+		case 40, 41, 42, 43, 44, 45, 46, 47, 49:
+			f.Bg = codeColors[x-40]
+			// 38 and 48 not supported. Maybe someday.
+		default:
+			unsupported = append(unsupported, x)
+		}
+	}
+
+	if unsupported != nil {
+		return supportError(fmt.Errorf("unknown attributes: %v", unsupported))
+	}
+	return nil
+}
+
+func relativeMove(y, x int) func(*VT100, []int) error {
+	return func(v *VT100, args []int) error {
+		c := 1
+		if len(args) >= 1 {
+			c = args[0]
+		}
+		// home is 1-indexed, because that's what the terminal sends us. We want to
+		// reuse its sanitization scheme, so we'll just modify our args by that amount.
+		return home(v, []int{v.Cursor.Y + y*c + 1, v.Cursor.X + x*c + 1})
+	}
+}
+
+func eraseColumns(v *VT100, args []int) error {
+	d := eraseForward
+	if len(args) > 0 {
+		d = eraseDirection(args[0])
+	}
+	if d > eraseAll {
+		return fmt.Errorf("unknown erase direction: %d", d)
+	}
+	v.eraseColumns(d)
+	return nil
+}
+
+func eraseLines(v *VT100, args []int) error {
+	d := eraseForward
+	if len(args) > 0 {
+		d = eraseDirection(args[0])
+	}
+	if d > eraseAll {
+		return fmt.Errorf("unknown erase direction: %d", d)
+	}
+	v.eraseLines(d)
+	return nil
+}
+
+func sanitize(v *VT100, y, x int) (int, int, error) {
+	var err error
+	if y < 0 || y >= v.Height || x < 0 || x >= v.Width {
+		err = fmt.Errorf("out of bounds (%d, %d)", y, x)
+	} else {
+		return y, x, nil
+	}
+
+	if y < 0 {
+		y = 0
+	}
+	if y >= v.Height {
+		y = v.Height - 1
+	}
+	if x < 0 {
+		x = 0
+	}
+	if x >= v.Width {
+		x = v.Width - 1
+	}
+	return y, x, err
+}
+
+func home(v *VT100, args []int) error {
+	var y, x int
+	if len(args) >= 2 {
+		y, x = args[0]-1, args[1]-1 // home args are 1-indexed.
+	}
+	y, x, err := sanitize(v, y, x) // Clamp y and x to the bounds of the terminal.
+	v.home(y, x)                   // Try to do something like what the client asked.
+	return err
+}
+
+func (c escapeCommand) display(v *VT100) error {
+	f, ok := intHandlers[c.cmd]
+	if !ok {
+		return supportError(c.err(errors.New("unsupported command")))
+	}
+
+	args, err := c.argInts()
+	if err != nil {
+		return c.err(fmt.Errorf("while parsing int args: %v", err))
+	}
+
+	return f(v, args)
+}
+
+// err enhances e with information about the current escape command
+func (c escapeCommand) err(e error) error {
+	return fmt.Errorf("%s: %s", c, e)
+}
+
+var csArgsRe = regexp.MustCompile("^([^0-9]*)(.*)$")
+
+// argInts parses c.args as a slice of at least arity ints. If the number
+// of ; separated arguments is less than arity, the remaining elements of
+// the result will be zero. errors only on integer parsing failure.
+func (c escapeCommand) argInts() ([]int, error) {
+	if len(c.args) == 0 {
+		return make([]int, 0), nil
+	}
+	args := strings.Split(c.args, ";")
+	out := make([]int, len(args))
+	for i, s := range args {
+		x, err := strconv.ParseInt(s, 10, 0)
+		if err != nil {
+			return nil, err
+		}
+		out[i] = int(x)
+	}
+	return out, nil
+}
+
+type controlCommand rune
+
+const (
+	backspace      controlCommand = '\b'
+	_horizontalTab                = '\t'
+	linefeed                      = '\n'
+	_verticalTab                  = '\v'
+	_formfeed                     = '\f'
+	carriageReturn                = '\r'
+)
+
+func (c controlCommand) display(v *VT100) error {
+	switch c {
+	case backspace:
+		v.backspace()
+	case linefeed:
+		v.Cursor.Y++
+		v.Cursor.X = 0
+	case carriageReturn:
+		v.Cursor.X = 0
+	}
+	return nil
+}

+ 97 - 0
vendor/github.com/tonistiigi/vt100/scanner.go

@@ -0,0 +1,97 @@
+package vt100
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"unicode"
+)
+
+// Decode decodes one ANSI terminal command from s.
+//
+// s should be connected to a client program that expects an
+// ANSI terminal on the other end. It will push bytes to us that we are meant
+// to intepret as terminal control codes, or text to place onto the terminal.
+//
+// This Command alone does not actually update the terminal. You need to pass
+// it to VT100.Process().
+//
+// You should not share s with any other reader, because it could leave
+// the stream in an invalid state.
+func Decode(s io.RuneScanner) (Command, error) {
+	r, size, err := s.ReadRune()
+	if err != nil {
+		return nil, err
+	}
+
+	if r == unicode.ReplacementChar && size == 1 {
+		return nil, fmt.Errorf("non-utf8 data from reader")
+	}
+
+	if r == escape || r == monogramCsi { // At beginning of escape sequence.
+		s.UnreadRune()
+		return scanEscapeCommand(s)
+	}
+
+	if unicode.IsControl(r) {
+		return controlCommand(r), nil
+	}
+
+	return runeCommand(r), nil
+}
+
+const (
+	// There are two ways to begin an escape sequence. One is to put the escape byte.
+	// The other is to put the single-rune control sequence indicator, which is equivalent
+	// to putting "\u001b[".
+	escape      = '\u001b'
+	monogramCsi = '\u009b'
+)
+
+var (
+	csEnd = &unicode.RangeTable{R16: []unicode.Range16{{Lo: 64, Hi: 126, Stride: 1}}}
+)
+
+// scanEscapeCommand scans to the end of the current escape sequence. The scanner
+// must be positioned at an escape rune (esc or the unicode CSI).
+func scanEscapeCommand(s io.RuneScanner) (Command, error) {
+	csi := false
+	esc, _, err := s.ReadRune()
+	if err != nil {
+		return nil, err
+	}
+	if esc != escape && esc != monogramCsi {
+		return nil, fmt.Errorf("invalid content")
+	}
+	if esc == monogramCsi {
+		csi = true
+	}
+
+	var args bytes.Buffer
+	quote := false
+	for i := 0; ; i++ {
+		r, _, err := s.ReadRune()
+		if err != nil {
+			return nil, err
+		}
+		if i == 0 && r == '[' {
+			csi = true
+			continue
+		}
+
+		if !csi {
+			return escapeCommand{r, ""}, nil
+		} else if quote == false && unicode.Is(csEnd, r) {
+			return escapeCommand{r, args.String()}, nil
+		}
+
+		if r == '"' {
+			quote = !quote
+		}
+
+		// Otherwise, we're still in the args, and this rune is one of those args.
+		if _, err := args.WriteRune(r); err != nil {
+			panic(err) // WriteRune cannot return an error from bytes.Buffer.
+		}
+	}
+}

+ 435 - 0
vendor/github.com/tonistiigi/vt100/vt100.go

@@ -0,0 +1,435 @@
+// package vt100 implements a quick-and-dirty programmable ANSI terminal emulator.
+//
+// You could, for example, use it to run a program like nethack that expects
+// a terminal as a subprocess. It tracks the position of the cursor,
+// colors, and various other aspects of the terminal's state, and
+// allows you to inspect them.
+//
+// We do very much mean the dirty part. It's not that we think it might have
+// bugs. It's that we're SURE it does. Currently, we only handle raw mode, with no
+// cooked mode features like scrolling. We also misinterpret some of the control
+// codes, which may or may not matter for your purpose.
+package vt100
+
+import (
+	"bytes"
+	"fmt"
+	"image/color"
+	"sort"
+	"strings"
+)
+
+type Intensity int
+
+const (
+	Normal Intensity = 0
+	Bright           = 1
+	Dim              = 2
+	// TODO(jaguilar): Should this be in a subpackage, since the names are pretty collide-y?
+)
+
+var (
+	// Technically RGBAs are supposed to be premultiplied. But CSS doesn't expect them
+	// that way, so we won't do it in this file.
+	DefaultColor = color.RGBA{0, 0, 0, 0}
+	// Our black has 255 alpha, so it will compare negatively with DefaultColor.
+	Black   = color.RGBA{0, 0, 0, 255}
+	Red     = color.RGBA{255, 0, 0, 255}
+	Green   = color.RGBA{0, 255, 0, 255}
+	Yellow  = color.RGBA{255, 255, 0, 255}
+	Blue    = color.RGBA{0, 0, 255, 255}
+	Magenta = color.RGBA{255, 0, 255, 255}
+	Cyan    = color.RGBA{0, 255, 255, 255}
+	White   = color.RGBA{255, 255, 255, 255}
+)
+
+func (i Intensity) alpha() uint8 {
+	switch i {
+	case Bright:
+		return 255
+	case Normal:
+		return 170
+	case Dim:
+		return 85
+	default:
+		return 170
+	}
+}
+
+// Format represents the display format of text on a terminal.
+type Format struct {
+	// Fg is the foreground color.
+	Fg color.RGBA
+	// Bg is the background color.
+	Bg color.RGBA
+	// Intensity is the text intensity (bright, normal, dim).
+	Intensity Intensity
+	// Various text properties.
+	Underscore, Conceal, Negative, Blink, Inverse bool
+}
+
+func toCss(c color.RGBA) string {
+	return fmt.Sprintf("rgba(%d, %d, %d, %f)", c.R, c.G, c.B, float32(c.A)/255)
+}
+
+func (f Format) css() string {
+	parts := make([]string, 0)
+	fg, bg := f.Fg, f.Bg
+	if f.Inverse {
+		bg, fg = fg, bg
+	}
+
+	if f.Intensity != Normal {
+		// Intensity only applies to the text -- i.e., the foreground.
+		fg.A = f.Intensity.alpha()
+	}
+
+	if fg != DefaultColor {
+		parts = append(parts, "color:"+toCss(fg))
+	}
+	if bg != DefaultColor {
+		parts = append(parts, "background-color:"+toCss(bg))
+	}
+	if f.Underscore {
+		parts = append(parts, "text-decoration:underline")
+	}
+	if f.Conceal {
+		parts = append(parts, "display:none")
+	}
+	if f.Blink {
+		parts = append(parts, "text-decoration:blink")
+	}
+
+	// We're not in performance sensitive code. Although this sort
+	// isn't strictly necessary, it gives us the nice property that
+	// the style of a particular set of attributes will always be
+	// generated the same way. As a result, we can use the html
+	// output in tests.
+	sort.StringSlice(parts).Sort()
+
+	return strings.Join(parts, ";")
+}
+
+// Cursor represents both the position and text type of the cursor.
+type Cursor struct {
+	// Y and X are the coordinates.
+	Y, X int
+
+	// F is the format that will be displayed.
+	F Format
+}
+
+// VT100 represents a simplified, raw VT100 terminal.
+type VT100 struct {
+	// Height and Width are the dimensions of the terminal.
+	Height, Width int
+
+	// Content is the text in the terminal.
+	Content [][]rune
+
+	// Format is the display properties of each cell.
+	Format [][]Format
+
+	// Cursor is the current state of the cursor.
+	Cursor Cursor
+
+	// savedCursor is the state of the cursor last time save() was called.
+	savedCursor Cursor
+
+	unparsed []byte
+}
+
+// NewVT100 creates a new VT100 object with the specified dimensions. y and x
+// must both be greater than zero.
+//
+// Each cell is set to contain a ' ' rune, and all formats are left as the
+// default.
+func NewVT100(y, x int) *VT100 {
+	if y == 0 || x == 0 {
+		panic(fmt.Errorf("invalid dim (%d, %d)", y, x))
+	}
+
+	v := &VT100{
+		Height:  y,
+		Width:   x,
+		Content: make([][]rune, y),
+		Format:  make([][]Format, y),
+	}
+
+	for row := 0; row < y; row++ {
+		v.Content[row] = make([]rune, x)
+		v.Format[row] = make([]Format, x)
+
+		for col := 0; col < x; col++ {
+			v.clear(row, col)
+		}
+	}
+	return v
+}
+
+func (v *VT100) UsedHeight() int {
+	count := 0
+	for _, l := range v.Content {
+		for _, r := range l {
+			if r != ' ' {
+				count++
+				break
+			}
+		}
+	}
+	return count
+}
+
+func (v *VT100) Resize(y, x int) {
+	if y > v.Height {
+		n := y - v.Height
+		for row := 0; row < n; row++ {
+			v.Content = append(v.Content, make([]rune, v.Width))
+			v.Format = append(v.Format, make([]Format, v.Width))
+			for col := 0; col < v.Width; col++ {
+				v.clear(v.Height+row, col)
+			}
+		}
+		v.Height = y
+	} else if y < v.Height {
+		v.Content = v.Content[:y]
+		v.Height = y
+	}
+
+	if x > v.Width {
+		for i := range v.Content {
+			row := make([]rune, x)
+			copy(row, v.Content[i])
+			v.Content[i] = row
+			format := make([]Format, x)
+			copy(format, v.Format[i])
+			v.Format[i] = format
+			for j := v.Width; j < x; j++ {
+				v.clear(i, j)
+			}
+		}
+		v.Width = x
+	} else if x < v.Width {
+		for i := range v.Content {
+			v.Content[i] = v.Content[i][:x]
+			v.Format[i] = v.Format[i][:x]
+		}
+		v.Width = x
+	}
+}
+
+func (v *VT100) Write(dt []byte) (int, error) {
+	n := len(dt)
+	if len(v.unparsed) > 0 {
+		dt = append(v.unparsed, dt...) // this almost never happens
+		v.unparsed = nil
+	}
+	buf := bytes.NewBuffer(dt)
+	for {
+		if buf.Len() == 0 {
+			return n, nil
+		}
+		cmd, err := Decode(buf)
+		if err != nil {
+			if l := buf.Len(); l > 0 && l < 12 { // on small leftover handle unparsed, otherwise skip
+				v.unparsed = buf.Bytes()
+			}
+			return n, nil
+		}
+		v.Process(cmd) // ignore error
+	}
+}
+
+// Process handles a single ANSI terminal command, updating the terminal
+// appropriately.
+//
+// One special kind of error that this can return is an UnsupportedError. It's
+// probably best to check for these and skip, because they are likely recoverable.
+// Support errors are exported as expvars, so it is possibly not necessary to log
+// them. If you want to check what's failed, start a debug http server and examine
+// the vt100-unsupported-commands field in /debug/vars.
+func (v *VT100) Process(c Command) error {
+	return c.display(v)
+}
+
+// HTML renders v as an HTML fragment. One idea for how to use this is to debug
+// the current state of the screen reader.
+func (v *VT100) HTML() string {
+	var buf bytes.Buffer
+	buf.WriteString(`<pre style="color:white;background-color:black;">`)
+
+	// Iterate each row. When the css changes, close the previous span, and open
+	// a new one. No need to close a span when the css is empty, we won't have
+	// opened one in the past.
+	var lastFormat Format
+	for y, row := range v.Content {
+		for x, r := range row {
+			f := v.Format[y][x]
+			if f != lastFormat {
+				if lastFormat != (Format{}) {
+					buf.WriteString("</span>")
+				}
+				if f != (Format{}) {
+					buf.WriteString(`<span style="` + f.css() + `">`)
+				}
+				lastFormat = f
+			}
+			if s := maybeEscapeRune(r); s != "" {
+				buf.WriteString(s)
+			} else {
+				buf.WriteRune(r)
+			}
+		}
+		buf.WriteRune('\n')
+	}
+	buf.WriteString("</pre>")
+
+	return buf.String()
+}
+
+// maybeEscapeRune potentially escapes a rune for display in an html document.
+// It only escapes the things that html.EscapeString does, but it works without allocating
+// a string to hold r. Returns an empty string if there is no need to escape.
+func maybeEscapeRune(r rune) string {
+	switch r {
+	case '&':
+		return "&amp;"
+	case '\'':
+		return "&#39;"
+	case '<':
+		return "&lt;"
+	case '>':
+		return "&gt;"
+	case '"':
+		return "&quot;"
+	}
+	return ""
+}
+
+// put puts r onto the current cursor's position, then advances the cursor.
+func (v *VT100) put(r rune) {
+	v.scrollIfNeeded()
+	v.Content[v.Cursor.Y][v.Cursor.X] = r
+	v.Format[v.Cursor.Y][v.Cursor.X] = v.Cursor.F
+	v.advance()
+}
+
+// advance advances the cursor, wrapping to the next line if need be.
+func (v *VT100) advance() {
+	v.Cursor.X++
+	if v.Cursor.X >= v.Width {
+		v.Cursor.X = 0
+		v.Cursor.Y++
+	}
+	// if v.Cursor.Y >= v.Height {
+	// 	// TODO(jaguilar): if we implement scroll, this should probably scroll.
+	// 	// v.Cursor.Y = 0
+	// 	v.scroll()
+	// }
+}
+
+func (v *VT100) scrollIfNeeded() {
+	if v.Cursor.Y >= v.Height {
+		first := v.Content[0]
+		copy(v.Content, v.Content[1:])
+		for i := range first {
+			first[i] = ' '
+		}
+		v.Content[v.Height-1] = first
+		v.Cursor.Y = v.Height - 1
+	}
+}
+
+// home moves the cursor to the coordinates y x. If y x are out of bounds, v.Err
+// is set.
+func (v *VT100) home(y, x int) {
+	v.Cursor.Y, v.Cursor.X = y, x
+}
+
+// eraseDirection is the logical direction in which an erase command happens,
+// from the cursor. For both erase commands, forward is 0, backward is 1,
+// and everything is 2.
+type eraseDirection int
+
+const (
+	// From the cursor to the end, inclusive.
+	eraseForward eraseDirection = iota
+
+	// From the beginning to the cursor, inclusive.
+	eraseBack
+
+	// Everything.
+	eraseAll
+)
+
+// eraseColumns erases columns from the current line.
+func (v *VT100) eraseColumns(d eraseDirection) {
+	y, x := v.Cursor.Y, v.Cursor.X // Aliases for simplicity.
+	switch d {
+	case eraseBack:
+		v.eraseRegion(y, 0, y, x)
+	case eraseForward:
+		v.eraseRegion(y, x, y, v.Width-1)
+	case eraseAll:
+		v.eraseRegion(y, 0, y, v.Width-1)
+	}
+}
+
+// eraseLines erases lines from the current terminal. Note that
+// no matter what is selected, the entire current line is erased.
+func (v *VT100) eraseLines(d eraseDirection) {
+	y := v.Cursor.Y // Alias for simplicity.
+	switch d {
+	case eraseBack:
+		v.eraseRegion(0, 0, y, v.Width-1)
+	case eraseForward:
+		v.eraseRegion(y, 0, v.Height-1, v.Width-1)
+	case eraseAll:
+		v.eraseRegion(0, 0, v.Height-1, v.Width-1)
+	}
+}
+
+func (v *VT100) eraseRegion(y1, x1, y2, x2 int) {
+	// Do not sanitize or bounds-check these coordinates, since they come from the
+	// programmer (me). We should panic if any of them are out of bounds.
+	if y1 > y2 {
+		y1, y2 = y2, y1
+	}
+	if x1 > x2 {
+		x1, x2 = x2, x1
+	}
+
+	for y := y1; y <= y2; y++ {
+		for x := x1; x <= x2; x++ {
+			v.clear(y, x)
+		}
+	}
+}
+
+func (v *VT100) clear(y, x int) {
+	if y >= len(v.Content) || x >= len(v.Content[0]) {
+		return
+	}
+	v.Content[y][x] = ' '
+	v.Format[y][x] = Format{}
+}
+
+func (v *VT100) backspace() {
+	v.Cursor.X--
+	if v.Cursor.X < 0 {
+		if v.Cursor.Y == 0 {
+			v.Cursor.X = 0
+		} else {
+			v.Cursor.Y--
+			v.Cursor.X = v.Width - 1
+		}
+	}
+}
+
+func (v *VT100) save() {
+	v.savedCursor = v.Cursor
+}
+
+func (v *VT100) unsave() {
+	v.Cursor = v.savedCursor
+}

+ 4 - 0
vendor/modules.txt

@@ -755,6 +755,7 @@ github.com/moby/buildkit/util/overlay
 github.com/moby/buildkit/util/progress
 github.com/moby/buildkit/util/progress/controller
 github.com/moby/buildkit/util/progress/logs
+github.com/moby/buildkit/util/progress/progressui
 github.com/moby/buildkit/util/pull
 github.com/moby/buildkit/util/pull/pullprogress
 github.com/moby/buildkit/util/purl
@@ -1000,6 +1001,9 @@ github.com/tonistiigi/go-archvariant
 # github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea
 ## explicit
 github.com/tonistiigi/units
+# github.com/tonistiigi/vt100 v0.0.0-20210615222946-8066bb97264f
+## explicit; go 1.12
+github.com/tonistiigi/vt100
 # github.com/vbatts/tar-split v0.11.2
 ## explicit; go 1.15
 github.com/vbatts/tar-split/archive/tar