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>
This commit is contained in:
Brian Goff 2023-08-24 23:45:20 +00:00
parent 3b4ccb2eca
commit 9b7784781d
14 changed files with 2407 additions and 0 deletions

View file

@ -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))
}

View file

@ -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

View file

@ -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=

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
vendor/github.com/tonistiigi/vt100/.travis.yml generated vendored Normal file
View file

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

22
vendor/github.com/tonistiigi/vt100/LICENSE generated vendored Normal file
View file

@ -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
vendor/github.com/tonistiigi/vt100/README.md generated vendored Normal file
View file

@ -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
vendor/github.com/tonistiigi/vt100/command.go generated vendored Normal file
View file

@ -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
vendor/github.com/tonistiigi/vt100/scanner.go generated vendored Normal file
View file

@ -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
vendor/github.com/tonistiigi/vt100/vt100.go generated vendored Normal file
View file

@ -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
vendor/modules.txt vendored
View file

@ -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