github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/build/buildkit_printer.go (about)

     1  package build
     2  
     3  import (
     4  	"fmt"
     5  	"regexp"
     6  	"strings"
     7  	"time"
     8  
     9  	digest "github.com/opencontainers/go-digest"
    10  	"github.com/tonistiigi/units"
    11  
    12  	"github.com/tilt-dev/tilt/pkg/apis"
    13  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    14  	"github.com/tilt-dev/tilt/pkg/logger"
    15  )
    16  
    17  type buildkitPrinter struct {
    18  	logger logger.Logger
    19  	vData  map[digest.Digest]*vertexAndLogs
    20  	vOrder []digest.Digest
    21  }
    22  
    23  type vertex struct {
    24  	digest          digest.Digest
    25  	name            string
    26  	error           string
    27  	started         bool
    28  	startedTime     *time.Time
    29  	completed       bool
    30  	completedTime   *time.Time
    31  	startPrinted    bool
    32  	errorPrinted    bool
    33  	completePrinted bool
    34  	durationPrinted time.Duration
    35  	cached          bool
    36  	duration        time.Duration
    37  }
    38  
    39  const internalPrefix = "[internal]"
    40  const internalLoader = "[internal] load build context"
    41  const logPrefix = "  → "
    42  
    43  var stageNameRegexp = regexp.MustCompile(`^\[.+\]`)
    44  
    45  func (v *vertex) shouldHide() bool {
    46  	return strings.HasPrefix(v.name, internalPrefix) && v.name != internalLoader
    47  }
    48  
    49  func (v *vertex) isError() bool {
    50  	return len(v.error) > 0
    51  }
    52  
    53  func (v *vertex) humanName() string {
    54  	if v.name == internalLoader {
    55  		return "[background] read source files"
    56  	}
    57  	return v.name
    58  }
    59  
    60  func (v *vertex) stageName() string {
    61  	match := stageNameRegexp.FindString(v.name)
    62  	if match == "" {
    63  		// If we couldn't find a match, just return the whole
    64  		// human-readable name, so that the user has some hope of figuring out
    65  		// what went wrong.
    66  		return v.humanName()
    67  	}
    68  	return match
    69  }
    70  
    71  type vertexAndLogs struct {
    72  	vertex      *vertex
    73  	logs        []*vertexLog
    74  	logsPrinted int
    75  	logger      logger.Logger
    76  
    77  	// A map of statuses, indexed by the layer id being downloaded.
    78  	statuses vertexStatusSet
    79  
    80  	// A combined status for all the downloadable layers, merged
    81  	// into a single status object.
    82  	lastPrintedStatus vertexStatus
    83  }
    84  
    85  type vertexLog struct {
    86  	vertex digest.Digest
    87  	msg    []byte
    88  }
    89  
    90  type vertexStatus struct {
    91  	vertex    digest.Digest
    92  	id        string
    93  	total     int64
    94  	current   int64
    95  	timestamp time.Time
    96  }
    97  
    98  // The buildkit protocol represents each downloadable layer
    99  // as a separate status object, identified by a layer ID.
   100  // We want to present this to the user as a single, combined status
   101  // that summarizes all layers.
   102  type vertexStatusSet map[string]vertexStatus
   103  
   104  func (s vertexStatusSet) combined() vertexStatus {
   105  	current := int64(0)
   106  	total := int64(0)
   107  	t := time.Time{}
   108  	for _, v := range s {
   109  		current += v.current
   110  		total += v.total
   111  		if v.timestamp.After(t) {
   112  			t = v.timestamp
   113  		}
   114  	}
   115  	return vertexStatus{
   116  		current:   current,
   117  		total:     total,
   118  		timestamp: t,
   119  	}
   120  }
   121  
   122  func newBuildkitPrinter(l logger.Logger) *buildkitPrinter {
   123  	return &buildkitPrinter{
   124  		logger: l,
   125  		vData:  map[digest.Digest]*vertexAndLogs{},
   126  		vOrder: []digest.Digest{},
   127  	}
   128  }
   129  
   130  func (b *buildkitPrinter) toStageStatuses() []v1alpha1.DockerImageStageStatus {
   131  	result := make([]v1alpha1.DockerImageStageStatus, 0)
   132  	for _, digest := range b.vOrder {
   133  		if vl, ok := b.vData[digest]; ok {
   134  			v := vl.vertex
   135  			status := v1alpha1.DockerImageStageStatus{
   136  				Name:   v.name,
   137  				Cached: v.cached,
   138  			}
   139  			if v.startedTime != nil {
   140  				st := apis.NewMicroTime(*v.startedTime)
   141  				status.StartedAt = &st
   142  			}
   143  			if v.completedTime != nil {
   144  				ct := apis.NewMicroTime(*v.completedTime)
   145  				status.FinishedAt = &ct
   146  			}
   147  			if v.isError() {
   148  				status.Error = v.error
   149  			}
   150  			result = append(result, status)
   151  		}
   152  	}
   153  	return result
   154  }
   155  
   156  func (b *buildkitPrinter) parseAndPrint(vertexes []*vertex, logs []*vertexLog, statuses []*vertexStatus) error {
   157  	for _, v := range vertexes {
   158  		if vl, ok := b.vData[v.digest]; ok {
   159  			vl.vertex.started = v.started
   160  			vl.vertex.completed = v.completed
   161  			vl.vertex.startedTime = v.startedTime
   162  			vl.vertex.completedTime = v.completedTime
   163  			vl.vertex.cached = v.cached
   164  
   165  			// NOTE(nick): Fun fact! The buildkit protocol sends down multiple completion timestamps.
   166  			// We need to take the last one.
   167  			if v.duration > vl.vertex.duration {
   168  				vl.vertex.duration = v.duration
   169  			}
   170  
   171  			if v.isError() {
   172  				vl.vertex.error = v.error
   173  			}
   174  		} else {
   175  			b.vData[v.digest] = &vertexAndLogs{
   176  				vertex: v,
   177  				logs:   []*vertexLog{},
   178  				logger: logger.NewPrefixedLogger(logPrefix, b.logger),
   179  			}
   180  
   181  			b.vOrder = append(b.vOrder, v.digest)
   182  		}
   183  	}
   184  
   185  	for _, l := range logs {
   186  		if vl, ok := b.vData[l.vertex]; ok {
   187  			vl.logs = append(vl.logs, l)
   188  		}
   189  	}
   190  
   191  	for _, s := range statuses {
   192  		if vl, ok := b.vData[s.vertex]; ok {
   193  			if vl.statuses == nil {
   194  				vl.statuses = vertexStatusSet{}
   195  			}
   196  			vl.statuses[s.id] = *s
   197  		}
   198  	}
   199  
   200  	for _, d := range b.vOrder {
   201  		vl, ok := b.vData[d]
   202  		if !ok {
   203  			return fmt.Errorf("Expected to find digest %s in %+v", d, b.vData)
   204  		}
   205  
   206  		v := vl.vertex
   207  		if v.started && !v.startPrinted && !v.shouldHide() {
   208  			cacheSuffix := ""
   209  			if v.cached {
   210  				cacheSuffix = " [cached]"
   211  			}
   212  			b.logger.WithFields(logger.Fields{logger.FieldNameProgressID: v.stageName()}).
   213  				Infof("%s%s", v.humanName(), cacheSuffix)
   214  			v.startPrinted = true
   215  		}
   216  
   217  		if v.isError() && !v.errorPrinted {
   218  			// TODO(nick): Should this be logger.Errorf?
   219  			b.logger.Infof("\nERROR IN: %s", v.humanName())
   220  			v.errorPrinted = true
   221  		}
   222  
   223  		if v.isError() || !v.shouldHide() {
   224  			b.flushLogs(vl)
   225  		}
   226  
   227  		if !v.shouldHide() &&
   228  			!v.cached &&
   229  			!v.isError() {
   230  
   231  			var progressInBytes string
   232  			status := vl.statuses.combined()
   233  			shouldPrintProgress := false
   234  			if vl.lastPrintedStatus.total != status.total {
   235  				// print progress when the total has changed. That means we've started
   236  				// downloading a new layer.
   237  				shouldPrintProgress = true
   238  			} else if status.total > 0 {
   239  				// print progress when at least 1% of total has changed and at least 2 seconds have passed.
   240  				diff := float64(status.current) - float64(vl.lastPrintedStatus.current)
   241  				largeEnoughChange := diff/float64(status.total) >= 0.01
   242  				largeEnoughTime := status.timestamp.Sub(vl.lastPrintedStatus.timestamp) > 2*time.Second
   243  				shouldPrintProgress = largeEnoughChange && largeEnoughTime
   244  			} else if status.current > 0 {
   245  				// print progress when at least 5% of current has changed and at least 2 seconds have passed.
   246  				// We need to handle this case separately when we don't have a total estimate.
   247  				diff := float64(status.current) - float64(vl.lastPrintedStatus.current)
   248  				largeEnoughChange := diff/float64(status.current) >= 0.05
   249  				largeEnoughTime := status.timestamp.Sub(vl.lastPrintedStatus.timestamp) > 2*time.Second
   250  				shouldPrintProgress = largeEnoughChange && largeEnoughTime
   251  			}
   252  
   253  			if status.total != 0 {
   254  				progressInBytes = fmt.Sprintf(" %.2f / %.2f", units.Bytes(status.current), units.Bytes(status.total))
   255  			} else if status.current != 0 {
   256  				progressInBytes = fmt.Sprintf(" %.2f", units.Bytes(status.current))
   257  			}
   258  
   259  			// NOTE(nick): Fun fact! The buildkit protocol sends down multiple completion timestamps.
   260  			// We need to print the longest one.
   261  			shouldPrintCompletion := v.completed && v.duration > 10*time.Millisecond &&
   262  				(!v.completePrinted ||
   263  					v.durationPrinted < v.duration)
   264  
   265  			doneSuffix := ""
   266  			fields := logger.Fields{logger.FieldNameProgressID: v.stageName()}
   267  			if shouldPrintCompletion {
   268  				doneSuffix = fmt.Sprintf(" [done: %s]", v.duration.Truncate(time.Millisecond))
   269  				v.completePrinted = true
   270  				v.durationPrinted = v.duration
   271  				fields[logger.FieldNameProgressMustPrint] = "1"
   272  			}
   273  
   274  			if shouldPrintCompletion || shouldPrintProgress {
   275  				b.logger.WithFields(fields).
   276  					Infof("%s%s%s", v.humanName(), progressInBytes, doneSuffix)
   277  
   278  				vl.lastPrintedStatus = status
   279  			}
   280  		}
   281  	}
   282  
   283  	return nil
   284  }
   285  
   286  func (b *buildkitPrinter) flushLogs(vl *vertexAndLogs) {
   287  	for vl.logsPrinted < len(vl.logs) {
   288  		l := vl.logs[vl.logsPrinted]
   289  		vl.logsPrinted++
   290  		vl.logger.Write(logger.InfoLvl, l.msg)
   291  	}
   292  }